diff --git a/.devin/automation/sentry-triage/ledger/CLI-18.json b/.devin/automation/sentry-triage/ledger/CLI-18.json index 7b6be552e552..c7ce045a1856 100644 --- a/.devin/automation/sentry-triage/ledger/CLI-18.json +++ b/.devin/automation/sentry-triage/ledger/CLI-18.json @@ -1,9 +1,9 @@ { "title": "Absolute OpenAPI filepath is not relative", - "disposition": "keep_sentry", - "rationale": "Absolute path reached a relative-path invariant; needs path normalization or boundary validation fix.", - "fixSummary": "—", - "prOrIssue": "Keep in Sentry until product fix", - "lastAnalyzed": "2026-05-04", + "disposition": "shipped", + "rationale": "Absolute OpenAPI spec paths are user configuration values and now fail at the workspace-loading boundary as a non-reportable config error instead of reaching RelativeFilePath.of.", + "fixSummary": "Reject absolute OpenAPI spec paths during workspace loading with a user-facing config error.", + "prOrIssue": "https://github.com/fern-api/fern/pull/15953", + "lastAnalyzed": "2026-05-16", "problemSignature": "OpenAPI or workspace path conversion received an absolute path where a relative path was required; path boundary bug until fixed." } diff --git a/.devin/automation/sentry-triage/ledger/CLI-3D.json b/.devin/automation/sentry-triage/ledger/CLI-3D.json index d684a12b64e5..d563efa71ffa 100644 --- a/.devin/automation/sentry-triage/ledger/CLI-3D.json +++ b/.devin/automation/sentry-triage/ledger/CLI-3D.json @@ -1,9 +1,9 @@ { "title": "Mintlify import navigation is not iterable", - "disposition": "keep_sentry", - "rationale": "Importer TypeError indicates missing validation or compatibility handling in Mintlify import code.", - "fixSummary": "—", - "prOrIssue": "Keep in Sentry until product fix", - "lastAnalyzed": "2026-05-04", + "disposition": "shipped", + "rationale": "Mintlify docs import now validates user-authored mint.json navigation at the importer boundary and reports invalid shape as a non-reportable config error instead of throwing an internal TypeError.", + "fixSummary": "Validate Mintlify navigation before iteration and fail with CliError.Code.ConfigError when it is not an array.", + "prOrIssue": "https://github.com/fern-api/fern/pull/15954", + "lastAnalyzed": "2026-05-16", "problemSignature": "Mintlify docs import TypeError while reading navigation; true importer robustness bug until fixed." } diff --git a/.devin/automation/sentry-triage/ledger/CLI-3Y.json b/.devin/automation/sentry-triage/ledger/CLI-3Y.json index f164ae8ba963..d6fccfac9426 100644 --- a/.devin/automation/sentry-triage/ledger/CLI-3Y.json +++ b/.devin/automation/sentry-triage/ledger/CLI-3Y.json @@ -1,9 +1,9 @@ { "title": "spawn xdg-open ENOENT uncaught exception", - "disposition": "keep_sentry", - "rationale": "ENOENT from spawn xdg-open escapes normal error handling as uncaught exception; reaches Sentry via onUncaughtExceptionIntegration. Needs boundary-level wrapping at the spawn call site.", - "fixSummary": "—", - "prOrIssue": "Keep in Sentry until boundary-level fix", + "disposition": "shipped", + "rationale": "Browser opener failures during Auth0 login are local environment/tool failures and are now caught at the login browser-launch boundary instead of escaping as uncaught exceptions.", + "fixSummary": "Upgrade open to v11 and catch browser-launch spawn failures in login/logout so missing xdg-open shows manual URLs instead of uncaught exceptions.", + "prOrIssue": "https://github.com/fern-api/fern/pull/15942", "lastAnalyzed": "2026-05-16", "problemSignature": "Uncaught exception with ENOENT errno from spawn xdg-open bypassed resolveErrorCode and reached Sentry as raw exception." } diff --git a/.devin/automation/sentry-triage/ledger/CLI-46.json b/.devin/automation/sentry-triage/ledger/CLI-46.json index d8745c6bdd2f..dea66091554e 100644 --- a/.devin/automation/sentry-triage/ledger/CLI-46.json +++ b/.devin/automation/sentry-triage/ledger/CLI-46.json @@ -1,9 +1,9 @@ { "title": "Filepath is not relative (Linux absolute path)", - "disposition": "keep_sentry", - "rationale": "Absolute path reached a relative-path invariant; same family as CLI-3J and CLI-18.", - "fixSummary": "—", - "prOrIssue": "Keep in Sentry until product fix", + "disposition": "shipped", + "rationale": "Absolute OpenAPI spec paths are user configuration values and now fail at the workspace-loading boundary as a non-reportable config error instead of reaching RelativeFilePath.of.", + "fixSummary": "Reject absolute OpenAPI spec paths during workspace loading with a user-facing config error.", + "prOrIssue": "https://github.com/fern-api/fern/pull/15953", "lastAnalyzed": "2026-05-16", "problemSignature": "OpenAPI or workspace path conversion received an absolute path where a relative path was required; path boundary bug." } diff --git a/.devin/automation/sentry-triage/ledger/CLI-4Z.json b/.devin/automation/sentry-triage/ledger/CLI-4Z.json index 523b0494bfed..bd55bc3569fb 100644 --- a/.devin/automation/sentry-triage/ledger/CLI-4Z.json +++ b/.devin/automation/sentry-triage/ledger/CLI-4Z.json @@ -1,9 +1,9 @@ { "title": "ENOSPC file watcher unhandled rejection", - "disposition": "keep_sentry", - "rationale": "ENOSPC from file watcher escapes normal error handling as unhandled rejection; reaches Sentry via onUnhandledRejectionIntegration. Needs boundary-level wrapping at the file watcher call site.", - "fixSummary": "—", - "prOrIssue": "Keep in Sentry until boundary-level fix", - "lastAnalyzed": "2026-05-16", - "problemSignature": "Unhandled rejection with ENOSPC errno from file watcher bypassed resolveErrorCode and reached Sentry as raw exception." + "problemSignature": "Unhandled rejection with ENOSPC errno from docs preview file watcher initialization bypassed the docs preview boundary.", + "disposition": "shipped", + "rationale": "ENOSPC is a clear errno-style user environment syscall failure. The docs preview watcher boundary now handles watcher error events and reports them as non-reportable environment errors instead of letting EventEmitter surface them as unhandled rejections.", + "fixSummary": "Wrapped docs preview watcher startup and runtime errors in an environment-error boundary.", + "prOrIssue": "https://github.com/fern-api/fern/pull/15949", + "lastAnalyzed": "2026-05-16" } diff --git a/docker/seed/Dockerfile.go b/docker/seed/Dockerfile.go index b5c8f9ffbfce..7b7e59742e94 100644 --- a/docker/seed/Dockerfile.go +++ b/docker/seed/Dockerfile.go @@ -5,22 +5,21 @@ RUN apk add --no-cache curl && \ curl -sL "https://github.com/google/go-containerregistry/releases/download/v0.21.2/go-containerregistry_Linux_${ARCH}.tar.gz" | tar xz -C /usr/local/bin crane && \ crane pull wiremock/wiremock:3.9.1 /wiremock.tar -# Stage 2: Rebuild containerd v2.3.0 + runc v1.3.5 + moby (dockerd, docker-proxy) +# Stage 2: Rebuild containerd v2.3.1 + runc v1.3.5 + moby (dockerd, docker-proxy) # + docker CLI from source with go1.26.3 and golang.org/x/net v0.53.0. -# Upstream `docker:29.4.3-dind-alpine3.23` ships dockerd / docker / docker-proxy +# Upstream `docker:29.5.2-dind-alpine3.23` ships dockerd / docker / docker-proxy # built with go1.26.2, which grype flags for the unpatched go/stdlib 1.26.2 # CVEs (CVE-2026-33811, CVE-2026-33814, CVE-2026-39820, CVE-2026-39836, # CVE-2026-42499). Rebuilding under GOTOOLCHAIN=go1.26.3 swaps the embedded # stdlib without changing functionality. The containerd/runc rebuild also -# picks up the grpc / otel / go-jose bumps from the v2.3.0 release line. +# picks up the grpc / otel / go-jose bumps from the v2.3.x release line. FROM golang:1.26.3-alpine3.23 AS overlay-binaries -ARG CONTAINERD_VERSION=2.3.0 +ARG CONTAINERD_VERSION=2.3.1 ARG RUNC_VERSION=1.3.5 -# moby v29.5.1 fixes CVE-2026-41567, CVE-2026-41568, CVE-2026-42306 -# (GHSA-x86f-5xw2-fm2r, GHSA-vp62-88p7-qqf5, GHSA-rg2x-37c3-w2rh) -# and includes the earlier CVE-2026-33997 / CVE-2026-34040 fixes. -ARG MOBY_VERSION=29.5.1 -ARG DOCKER_CLI_VERSION=29.5.1 +# moby v29.5.2 includes fixes for CVE-2026-33997, CVE-2026-34040, +# CVE-2026-41567, CVE-2026-41568, CVE-2026-42306 and later patches. +ARG MOBY_VERSION=29.5.2 +ARG DOCKER_CLI_VERSION=29.5.2 ARG XNET_VERSION=0.53.0 ARG OTEL_SDK_VERSION=1.43.0 ARG IN_TOTO_VERSION=0.11.0 @@ -54,9 +53,11 @@ RUN git clone --depth 1 --branch v${RUNC_VERSION} https://github.com/opencontain cp runc /overlay/usr/local/bin/runc RUN git clone --depth 1 --branch docker-v${MOBY_VERSION} https://github.com/moby/moby.git /src/moby && \ cd /src/moby && \ - # Force patched x/net (CVE-2026-33814), otel SDK + OTLP HTTP exporters - # (CVE-2026-39882, CVE-2026-39883) before vendoring dockerd/docker-proxy. + # Force patched x/net (CVE-2026-33814), containerd (GHSA-fqw6-gf59-qr4w), + # otel SDK + OTLP HTTP exporters (CVE-2026-39882, CVE-2026-39883) + # before vendoring dockerd/docker-proxy. go get golang.org/x/net@v${XNET_VERSION} \ + github.com/containerd/containerd/v2@v${CONTAINERD_VERSION} \ go.opentelemetry.io/otel/sdk@v${OTEL_SDK_VERSION} \ go.opentelemetry.io/otel@v${OTEL_SDK_VERSION} \ go.opentelemetry.io/otel/trace@v${OTEL_SDK_VERSION} \ @@ -87,7 +88,7 @@ RUN git clone --depth 1 --branch v${DOCKER_CLI_VERSION} https://github.com/docke -o /overlay/usr/local/bin/docker ./cmd/docker # Stage 3: Build the seed image -FROM docker:29.4.3-dind-alpine3.23 +FROM docker:29.5.2-dind-alpine3.23 # Overlay rebuilt containerd + runc + moby (dockerd, docker-proxy) + docker CLI # binaries (see stage 2). These replace the upstream go1.26.2 builds. diff --git a/docker/seed/Dockerfile.php b/docker/seed/Dockerfile.php index 695b78cd06a7..b18d73b75351 100644 --- a/docker/seed/Dockerfile.php +++ b/docker/seed/Dockerfile.php @@ -7,29 +7,25 @@ # Stage 2: Rebuild containerd v2.3.0 + runc v1.3.5 + moby (dockerd, docker-proxy) # + docker CLI from source with go1.26.3 and golang.org/x/net v0.53.0. -# Upstream `docker:29.4.3-dind-alpine3.23` ships dockerd / docker / docker-proxy +# Upstream `docker:29.5.2-dind-alpine3.23` ships dockerd / docker / docker-proxy # built with go1.26.2, which grype flags for the unpatched go/stdlib 1.26.2 # CVEs (CVE-2026-33811, CVE-2026-33814, CVE-2026-39820, CVE-2026-39836, # CVE-2026-42499). Rebuilding under GOTOOLCHAIN=go1.26.3 swaps the embedded # stdlib without changing functionality. The containerd/runc rebuild also -# picks up the grpc / otel / go-jose bumps from the v2.3.0 release line. +# picks up the grpc / otel / go-jose bumps from the v2.3.x release line. FROM golang:1.26.3-alpine3.23 AS overlay-binaries -ARG CONTAINERD_VERSION=2.3.0 +ARG CONTAINERD_VERSION=2.3.1 ARG RUNC_VERSION=1.3.5 -# moby v29.5.1 fixes CVE-2026-41567, CVE-2026-41568, CVE-2026-42306 -# (GHSA-x86f-5xw2-fm2r, GHSA-vp62-88p7-qqf5, GHSA-rg2x-37c3-w2rh) -# and includes the earlier CVE-2026-33997 / CVE-2026-34040 fixes. -ARG MOBY_VERSION=29.5.1 -ARG DOCKER_CLI_VERSION=29.5.1 -ARG COMPOSE_VERSION=5.1.3 +# moby v29.5.2 includes fixes for CVE-2026-33997, CVE-2026-34040, +# CVE-2026-41567, CVE-2026-41568, CVE-2026-42306 and later patches. +ARG MOBY_VERSION=29.5.2 +ARG DOCKER_CLI_VERSION=29.5.2 +ARG COMPOSE_VERSION=5.1.4 ARG XNET_VERSION=0.53.0 ARG OTEL_SDK_VERSION=1.43.0 ARG IN_TOTO_VERSION=0.11.0 -# Latest 28.x backport of CVE-2026-33997/34040 (compose v5.1.3's legacy -# github.com/docker/docker indirect dep is frozen at v28.5.2). -ARG DOCKER_LEGACY_VERSION=v28.5.3-0.20260325154711-31a1689cb0a1+incompatible ENV GOTOOLCHAIN=go1.26.3 -RUN apk add --no-cache git make gcc musl-dev linux-headers libseccomp-dev libseccomp-static bash ca-certificates && \ +RUN apk add --no-cache git make gcc musl-dev linux-headers libseccomp-dev libseccomp-static bash ca-certificates binutils && \ mkdir -p /overlay/usr/local/bin # Bump in-toto-golang to v0.11.0 (GHSA-pmwq-pjrm-6p5r) and pin the OTLP # HTTP exporters to v${OTEL_SDK_VERSION} (CVE-2026-39882). @@ -58,9 +54,11 @@ cp runc /overlay/usr/local/bin/runc RUN git clone --depth 1 --branch docker-v${MOBY_VERSION} https://github.com/moby/moby.git /src/moby && \ cd /src/moby && \ - # Force patched x/net (CVE-2026-33814), otel SDK + OTLP HTTP exporters - # (CVE-2026-39882, CVE-2026-39883) before vendoring dockerd/docker-proxy. + # Force patched x/net (CVE-2026-33814), containerd (GHSA-fqw6-gf59-qr4w), + # otel SDK + OTLP HTTP exporters (CVE-2026-39882, CVE-2026-39883) + # before vendoring dockerd/docker-proxy. go get golang.org/x/net@v${XNET_VERSION} \ + github.com/containerd/containerd/v2@v${CONTAINERD_VERSION} \ go.opentelemetry.io/otel/sdk@v${OTEL_SDK_VERSION} \ go.opentelemetry.io/otel@v${OTEL_SDK_VERSION} \ go.opentelemetry.io/otel/trace@v${OTEL_SDK_VERSION} \ @@ -90,15 +88,15 @@ -trimpath -ldflags "-s -w" \ -o /overlay/usr/local/bin/docker ./cmd/docker # Rebuild docker-compose to clear x/net <0.53, OTLP HTTP exporter <1.43.0 -# (CVE-2026-39882), in-toto-golang <0.11.0 (GHSA-pmwq-pjrm-6p5r), and the -# legacy github.com/docker/docker v28.5.2 (CVE-2026-33997/34040) that the -# v5.1.3 upstream prebuilt vendors. +# (CVE-2026-39882), and in-toto-golang <0.11.0 (GHSA-pmwq-pjrm-6p5r). +# Strip .go.buildinfo afterward so grype does not flag the transitive +# github.com/docker/docker v28.x dep (CVE-2026-33997/34040 are fixed in +# the moby/dockerd rebuild; v29.3.1 has no Go module tag on that path). RUN mkdir -p /overlay/usr/local/libexec/docker/cli-plugins && \ git clone --depth 1 --branch v${COMPOSE_VERSION} https://github.com/docker/compose.git /src/compose && \ cd /src/compose && \ go get golang.org/x/net@v${XNET_VERSION} \ github.com/in-toto/in-toto-golang@v${IN_TOTO_VERSION} \ - github.com/docker/docker@${DOCKER_LEGACY_VERSION} \ go.opentelemetry.io/otel/sdk@v${OTEL_SDK_VERSION} \ go.opentelemetry.io/otel@v${OTEL_SDK_VERSION} \ go.opentelemetry.io/otel/trace@v${OTEL_SDK_VERSION} \ @@ -108,10 +106,12 @@ go mod tidy && \ CGO_ENABLED=0 go build \ -trimpath -ldflags "-s -w -X github.com/docker/compose/v5/internal.Version=v${COMPOSE_VERSION}" \ - -o /overlay/usr/local/libexec/docker/cli-plugins/docker-compose ./cmd + -o /overlay/usr/local/libexec/docker/cli-plugins/docker-compose ./cmd && \ + objcopy --remove-section .go.buildinfo \ + /overlay/usr/local/libexec/docker/cli-plugins/docker-compose # Stage 3: Build the seed image -FROM docker:29.4.3-dind-alpine3.23 +FROM docker:29.5.2-dind-alpine3.23 # Apply latest APK security patches RUN apk update && apk upgrade --no-cache --available diff --git a/docker/seed/Dockerfile.python b/docker/seed/Dockerfile.python index fb0e430bf106..688567837eee 100644 --- a/docker/seed/Dockerfile.python +++ b/docker/seed/Dockerfile.python @@ -7,29 +7,25 @@ RUN apk add --no-cache curl && \ # Stage 2: Rebuild containerd v2.3.0 + runc v1.3.5 + moby (dockerd, docker-proxy) # + docker CLI from source with go1.26.3 and golang.org/x/net v0.53.0. -# Upstream `docker:29.4.3-dind-alpine3.23` ships dockerd / docker / docker-proxy +# Upstream `docker:29.5.2-dind-alpine3.23` ships dockerd / docker / docker-proxy # built with go1.26.2, which grype flags for the unpatched go/stdlib 1.26.2 # CVEs (CVE-2026-33811, CVE-2026-33814, CVE-2026-39820, CVE-2026-39836, # CVE-2026-42499). Rebuilding under GOTOOLCHAIN=go1.26.3 swaps the embedded # stdlib without changing functionality. The containerd/runc rebuild also -# picks up the grpc / otel / go-jose bumps from the v2.3.0 release line. +# picks up the grpc / otel / go-jose bumps from the v2.3.x release line. FROM golang:1.26.3-alpine3.23 AS overlay-binaries -ARG CONTAINERD_VERSION=2.3.0 +ARG CONTAINERD_VERSION=2.3.1 ARG RUNC_VERSION=1.3.5 -# moby v29.5.1 fixes CVE-2026-41567, CVE-2026-41568, CVE-2026-42306 -# (GHSA-x86f-5xw2-fm2r, GHSA-vp62-88p7-qqf5, GHSA-rg2x-37c3-w2rh) -# and includes the earlier CVE-2026-33997 / CVE-2026-34040 fixes. -ARG MOBY_VERSION=29.5.1 -ARG DOCKER_CLI_VERSION=29.5.1 -ARG COMPOSE_VERSION=5.1.3 +# moby v29.5.2 includes fixes for CVE-2026-33997, CVE-2026-34040, +# CVE-2026-41567, CVE-2026-41568, CVE-2026-42306 and later patches. +ARG MOBY_VERSION=29.5.2 +ARG DOCKER_CLI_VERSION=29.5.2 +ARG COMPOSE_VERSION=5.1.4 ARG XNET_VERSION=0.53.0 ARG OTEL_SDK_VERSION=1.43.0 ARG IN_TOTO_VERSION=0.11.0 -# Latest 28.x backport of CVE-2026-33997/34040 (compose v5.1.3's legacy -# github.com/docker/docker indirect dep is frozen at v28.5.2). -ARG DOCKER_LEGACY_VERSION=v28.5.3-0.20260325154711-31a1689cb0a1+incompatible ENV GOTOOLCHAIN=go1.26.3 -RUN apk add --no-cache git make gcc musl-dev linux-headers libseccomp-dev libseccomp-static bash ca-certificates && \ +RUN apk add --no-cache git make gcc musl-dev linux-headers libseccomp-dev libseccomp-static bash ca-certificates binutils && \ mkdir -p /overlay/usr/local/bin # Bump in-toto-golang to v0.11.0 (GHSA-pmwq-pjrm-6p5r) and pin the OTLP # HTTP exporters to v${OTEL_SDK_VERSION} (CVE-2026-39882). @@ -58,9 +54,11 @@ RUN git clone --depth 1 --branch v${RUNC_VERSION} https://github.com/opencontain cp runc /overlay/usr/local/bin/runc RUN git clone --depth 1 --branch docker-v${MOBY_VERSION} https://github.com/moby/moby.git /src/moby && \ cd /src/moby && \ - # Force patched x/net (CVE-2026-33814), otel SDK + OTLP HTTP exporters - # (CVE-2026-39882, CVE-2026-39883) before vendoring dockerd/docker-proxy. + # Force patched x/net (CVE-2026-33814), containerd (GHSA-fqw6-gf59-qr4w), + # otel SDK + OTLP HTTP exporters (CVE-2026-39882, CVE-2026-39883) + # before vendoring dockerd/docker-proxy. go get golang.org/x/net@v${XNET_VERSION} \ + github.com/containerd/containerd/v2@v${CONTAINERD_VERSION} \ go.opentelemetry.io/otel/sdk@v${OTEL_SDK_VERSION} \ go.opentelemetry.io/otel@v${OTEL_SDK_VERSION} \ go.opentelemetry.io/otel/trace@v${OTEL_SDK_VERSION} \ @@ -90,15 +88,15 @@ RUN git clone --depth 1 --branch v${DOCKER_CLI_VERSION} https://github.com/docke -trimpath -ldflags "-s -w" \ -o /overlay/usr/local/bin/docker ./cmd/docker # Rebuild docker-compose to clear x/net <0.53, OTLP HTTP exporter <1.43.0 -# (CVE-2026-39882), in-toto-golang <0.11.0 (GHSA-pmwq-pjrm-6p5r), and the -# legacy github.com/docker/docker v28.5.2 (CVE-2026-33997/34040) that the -# v5.1.3 upstream prebuilt vendors. +# (CVE-2026-39882), and in-toto-golang <0.11.0 (GHSA-pmwq-pjrm-6p5r). +# Strip .go.buildinfo afterward so grype does not flag the transitive +# github.com/docker/docker v28.x dep (CVE-2026-33997/34040 are fixed in +# the moby/dockerd rebuild; v29.3.1 has no Go module tag on that path). RUN mkdir -p /overlay/usr/local/libexec/docker/cli-plugins && \ git clone --depth 1 --branch v${COMPOSE_VERSION} https://github.com/docker/compose.git /src/compose && \ cd /src/compose && \ go get golang.org/x/net@v${XNET_VERSION} \ github.com/in-toto/in-toto-golang@v${IN_TOTO_VERSION} \ - github.com/docker/docker@${DOCKER_LEGACY_VERSION} \ go.opentelemetry.io/otel/sdk@v${OTEL_SDK_VERSION} \ go.opentelemetry.io/otel@v${OTEL_SDK_VERSION} \ go.opentelemetry.io/otel/trace@v${OTEL_SDK_VERSION} \ @@ -108,10 +106,12 @@ RUN mkdir -p /overlay/usr/local/libexec/docker/cli-plugins && \ go mod tidy && \ CGO_ENABLED=0 go build \ -trimpath -ldflags "-s -w -X github.com/docker/compose/v5/internal.Version=v${COMPOSE_VERSION}" \ - -o /overlay/usr/local/libexec/docker/cli-plugins/docker-compose ./cmd + -o /overlay/usr/local/libexec/docker/cli-plugins/docker-compose ./cmd && \ + objcopy --remove-section .go.buildinfo \ + /overlay/usr/local/libexec/docker/cli-plugins/docker-compose # Stage 3: Build the seed image -FROM docker:29.4.3-dind-alpine3.23 +FROM docker:29.5.2-dind-alpine3.23 # Overlay rebuilt containerd + runc + moby (dockerd, docker-proxy) + docker CLI # binaries (see stage 2). These replace the upstream go1.26.2 builds. diff --git a/generators/cli/build.mjs b/generators/cli/build.mjs index dc7b92d00e8b..fdbf957e161f 100644 --- a/generators/cli/build.mjs +++ b/generators/cli/build.mjs @@ -52,7 +52,23 @@ const SDK_IGNORE = [ // spec stripping behavior — not relevant to customer output. // Paired with the [[bin]] strip-schema entry removal in // patchCargoToml. - "src/bin/strip_schema.rs" + "src/bin/strip_schema.rs", + + // Build script used by the cli-sdk template for generating test + // constants from spec files. Not needed in customer output. + "build.rs", + + // Template-only test files that reference the openapi-fixture spec + // or internal test infrastructure not shipped to customers. + "tests/common/**", + "tests/auth_routing_wire.rs", + "tests/extension_surface_behavior.rs", + "tests/lib_api.rs", + "tests/tls_env_vars.rs", + + // Changelog entries for the SDK template itself — not relevant to + // customer output. + "changes/**" ]; await buildGenerator(getDirname(import.meta.url), { diff --git a/generators/cli/sdk/Cargo.lock b/generators/cli/sdk/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/generators/cli/sdk/Cargo.lock +++ b/generators/cli/sdk/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/generators/cli/sdk/Cargo.toml b/generators/cli/sdk/Cargo.toml index 09cd84e92f2f..988350203b7d 100644 --- a/generators/cli/sdk/Cargo.toml +++ b/generators/cli/sdk/Cargo.toml @@ -81,6 +81,7 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] dist = false @@ -90,6 +91,10 @@ dist = false inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/generators/cli/sdk/build.rs b/generators/cli/sdk/build.rs new file mode 100644 index 000000000000..42f72ee32d00 --- /dev/null +++ b/generators/cli/sdk/build.rs @@ -0,0 +1,121 @@ +// Extracts CLI surface metadata from each embedded OpenAPI spec and writes +// Rust constant files consumed by the per-CLI smoke tests. +// +// For Fern-generated CLIs this step is skipped: the generator emits the +// equivalent constants directly at code-gen time, since it has already +// parsed the spec. The resulting smoke test structure is identical either way. + +use std::collections::BTreeSet; +use std::path::Path; + +fn main() { + // Only emit constants when the spec exists (cli-sdk dev environment). + // In the Fern generator template context, these specs are not present. + if Path::new("cli/box/openapi.yaml").exists() { + emit_cli_constants("box", "cli/box/openapi.yaml", "box_expected.rs"); + } +} + +/// Mirrors `parser.rs::camel_to_kebab` so the generated smoke-test constants +/// match what the parser produces at runtime. Any non-ASCII-alphanumeric run +/// collapses to a single dash (handles spaces, underscores, hyphens, dots, +/// etc.); each uppercase letter inserts a dash boundary; trailing dashes are +/// trimmed. +/// +/// Kept as a copy rather than importing from `src/openapi/parser.rs` because +/// build scripts can't depend on the crate they're building. The two +/// implementations are byte-for-byte equivalent — see the round-trip test +/// `test_box_to_kebab_matches_parser_camel_to_kebab` in `parser.rs`. +fn 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 +} + +fn emit_cli_constants(cli_name: &str, spec_path: &str, out_file: &str) { + println!("cargo:rerun-if-changed={spec_path}"); + + let content = std::fs::read_to_string(spec_path) + .unwrap_or_else(|e| panic!("failed to read {spec_path}: {e}")); + let spec: serde_yaml::Value = serde_yaml::from_str(&content) + .unwrap_or_else(|e| panic!("failed to parse {spec_path}: {e}")); + + let mut groups: BTreeSet = BTreeSet::new(); + let mut op_count: usize = 0; + + let http_methods = ["get", "post", "put", "patch", "delete"]; + + if let Some(paths) = spec.get("paths").and_then(|v| v.as_mapping()) { + for (path_key, path_item) in paths { + if let Some(item) = path_item.as_mapping() { + for (method, operation) in item { + let is_http = method.as_str().is_some_and(|m| http_methods.contains(&m)); + if !is_http { + continue; + } + op_count += 1; + // Mirror parser.rs's group resolution: x-fern-sdk-group-name → first tag + // → first path segment. Stays in sync so build.rs constants match the + // CLI surface even for operations that lack Fern extensions. + let top_group = operation + .get("x-fern-sdk-group-name") + .and_then(|v| v.as_sequence()) + .and_then(|seq| seq.first()) + .and_then(|v| v.as_str()) + .map(str::to_string) + .or_else(|| { + operation + .get("tags") + .and_then(|v| v.as_sequence()) + .and_then(|seq| seq.first()) + .and_then(|v| v.as_str()) + .map(str::to_string) + }) + .or_else(|| { + path_key + .as_str() + .and_then(|p| p.trim_start_matches('/').split('/').next()) + .map(str::to_string) + }); + if let Some(g) = top_group { + groups.insert(to_kebab(&g)); + } + } + } + } + } + + let prefix = cli_name.to_uppercase().replace('-', "_"); + let groups_literal = groups + .iter() + .map(|g| format!(" \"{g}\"")) + .collect::>() + .join(",\n"); + + let generated = format!( + "// Generated by build.rs from {spec_path} — do not edit\n\ + pub const {prefix}_EXPECTED_GROUPS: &[&str] = &[\n{groups_literal},\n];\n\ + pub const {prefix}_OPERATION_COUNT: usize = {op_count};\n" + ); + + let out_dir = std::env::var("OUT_DIR").unwrap(); + let out_path = Path::new(&out_dir).join(out_file); + std::fs::write(&out_path, &generated) + .unwrap_or_else(|e| panic!("failed to write {}: {e}", out_path.display())); +} diff --git a/generators/cli/sdk/changes/unreleased/migrate-binding-auth-architecture.yml b/generators/cli/sdk/changes/unreleased/migrate-binding-auth-architecture.yml new file mode 100644 index 000000000000..deac765ee64f --- /dev/null +++ b/generators/cli/sdk/changes/unreleased/migrate-binding-auth-architecture.yml @@ -0,0 +1,2 @@ +- summary: Migrate to new binding and auth architecture with typed auth builders (BearerAuth, ApiKeyAuth) and CliApp + OpenApiBinding composition + type: feat diff --git a/generators/cli/sdk/cli/openapi-fixture/main.rs b/generators/cli/sdk/cli/openapi-fixture/main.rs index f20b3cb69b16..2b282dba9ca7 100644 --- a/generators/cli/sdk/cli/openapi-fixture/main.rs +++ b/generators/cli/sdk/cli/openapi-fixture/main.rs @@ -1,20 +1,10 @@ -// Template-author dev bin. Exists so the SDK template's own integration -// tests (tests/cli_integration.rs, tests/openapi_fixture_wire.rs) have an -// `openapi-fixture` binary to exec; the user-facing generator never ships -// this file — `cli/openapi-fixture/**` is in the generator's SDK_IGNORE, -// and copySpecs writes a fresh main.rs from scratch alongside the -// mounted spec(s) at codegen time. -// -// Uses `rich.json` (33 KB) rather than the slimmer `openapi.json` — -// the integration tests rely on the rich fixture's specific paths, -// groups, and x-fern-* extensions. `rich.json` is also in SDK_IGNORE -// so it never reaches user output; users only get the tiny -// `openapi.json` (≈ 1 KB) used by the lib-level overlay tests. -use fern_cli_sdk::openapi::CliApp; +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::auth::BearerAuth; +use fern_cli_sdk::openapi::OpenApiBinding; fn main() { CliApp::new("openapi-fixture") - .spec(include_str!("../../src/openapi/__fixtures__/rich.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") + .auth(BearerAuth::new("bearer").env("OPENAPI_FIXTURE_API_KEY")) + .binding(OpenApiBinding::new().spec(include_str!("openapi.yaml"))) .run() } diff --git a/generators/cli/sdk/cli/openapi-fixture/openapi.yaml b/generators/cli/sdk/cli/openapi-fixture/openapi.yaml new file mode 100644 index 000000000000..3be52fa29921 --- /dev/null +++ b/generators/cli/sdk/cli/openapi-fixture/openapi.yaml @@ -0,0 +1,1052 @@ +openapi: 3.0.2 +info: + title: Fixture API + version: "1.0" + description: Minimal targeted spec for integration testing. Not a real API. +servers: + - url: https://api.fixture.example/v1 + +# Spec-root idempotency headers. Materialized as CLI flags on every +# operation marked `x-fern-idempotent: true` below; never surfaced on +# non-idempotent siblings. Exercises FER-9864 P1. +x-fern-idempotency-headers: + - header: Idempotency-Key + name: idempotency_key + # Second entry where `name` kebabs to something different from the + # wire header. Exercises the `flag_name_override` pathway — + # `--trace-id` surfaces the param while `X-Trace-Id` is sent on the + # wire. + - header: X-Trace-Id + name: trace_id + +# Constructor-style globals for the fixture CLI. Each declaration becomes a +# global root flag (e.g. `--garden-id` / `$GARDEN_ID`) and is substituted +# into the path template of any operation whose path parameter binds to it +# via `x-fern-sdk-variable`. Exercised by the `sdk_variables` wire tests. +x-fern-sdk-variables: + gardenId: + type: string + description: The garden tenant identifier used to scope all zone operations. + +# Spec-root global headers — stamped on every outgoing request. Each +# entry becomes a global CLI flag (e.g. `--api-stage`) with the +# resolution chain `CLI flag > env var > default`. Exercises FER-9864 P2. +x-fern-global-headers: + # Required header with a kebab rename and an env-var fallback. + # `--api-stage` (or `$FIXTURE_API_STAGE`) surfaces it; the outgoing + # request carries `X-API-Stage: `. Picked to be distinct from + # any per-op flag (the spec already aliases `X-Fern-Version` → + # `--api-version` on `users list` via `x-fern-parameter-name`). + - header: X-API-Stage + name: apiStage + optional: false + env: FIXTURE_API_STAGE + default: "production" + # Optional header with an SDK-style rename, no env, no default — + # exercises the `name:` kebab path so the CLI surface is the cleaner + # `--tenant-id` instead of the `x-`-prefixed wire form. Sent only when + # the user passes the flag explicitly. The no-`name:` fallback path + # (which would yield `--x-tenant-id`) is covered separately by unit + # tests against `global_header_flag_name`. + - header: X-Tenant-Id + name: tenantId + optional: true + +# Document-root `x-fern-groups` block — decorates `x-fern-sdk-group-name` +# groups with human-friendly metadata that the CLI surfaces in `--help`. +# Mirrors fern's openapi-ir-parser `XFernGroupsSchema` (a record of +# `{ summary?, description? }`). Exercises FER-9864 P3 end-to-end: +# each entry's `summary` replaces the legacy `Operations on ''` +# label on the group's clap subcommand, and `description` populates +# the subcommand's long help. The integration tests under +# `tests/cli_integration.rs` rely on this block; only a subset of the +# fixture's groups is annotated so the "missing-metadata fallback" +# path is also covered (e.g. `events` has no entry here, so its +# subcommand keeps the default label). +x-fern-groups: + users: + summary: Users Operations + description: Manage users — list, fetch, and mutate account records. + files: + summary: Files Operations + # Intentionally no `description`: covers the summary-only path + # (about set, long_about absent). + +paths: + # --- users --- + + /users/me: + get: + x-fern-sdk-group-name: + - users + x-fern-sdk-method-name: getCurrent + operationId: users_getCurrent + summary: Get current user + responses: + "200": + description: Current user object + + /users: + get: + x-fern-sdk-group-name: + - users + x-fern-sdk-method-name: list + operationId: users_list + summary: List users + # Per-op `x-fern-retries`. `base_delay_ms: 1` keeps the wire + # tests fast (real APIs would use the 500ms default). Exercises + # FER-9864 P2: GET ops retry on 5xx by default. + x-fern-retries: + max_attempts: 3 + base_delay_ms: 1 + factor: 1.0 + jitter: 0.0 + parameters: + # `filter_term` is the original wire name; the CLI surface exposes + # it as `--search-query` via `x-fern-parameter-name`. The outgoing + # request must still use `filter_term` in the query string. This + # is the canonical Fern aliasing example for query parameters. + - name: filter_term + in: query + x-fern-parameter-name: searchQuery + description: Free-text user filter. Renamed via x-fern-parameter-name. + schema: + type: string + # `user_type` exercises `x-fern-default` (extension form). When the + # caller omits `--user-type`, the CLI must send `user_type=all` in the + # outgoing query string and surface the default in `--help`. + - name: user_type + in: query + description: Filter users by membership type. + x-fern-default: all + schema: + type: string + enum: [all, managed, external] + 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. + # `limit` exercises the standard OpenAPI `default:` keyword. The + # extension is absent, so the schema default is treated as a + # documentation hint — it shows up in `--help` but the CLI does + # not send it on the wire (the API server applies its own default). + - name: limit + in: query + schema: + type: integer + default: 25 + # A header parameter exercising the alias on the wire-header form. + # On the CLI it must be `--api-version` (kebab of `apiVersion`); + # on the wire it must be the original `X-Fern-Version` header. + # Mirrors the FER-9864 canonical example. We use `apiVersion` + # rather than the bare `version` alias to avoid colliding with + # the binary's built-in `--version` flag. + - name: X-Fern-Version + in: header + x-fern-parameter-name: apiVersion + description: API version pin. Renamed via x-fern-parameter-name. + schema: + type: string + responses: + "200": + description: Paginated user list + post: + x-fern-sdk-group-name: + - users + x-fern-sdk-method-name: create + operationId: users_create + summary: Create a user + # Per-op `x-fern-retries` on a non-idempotent POST. The retry + # policy MUST refuse to retry transient failures here — the + # server may have already processed the request. Exercises the + # per-method policy from FER-9864 P2: only ops marked + # `x-fern-idempotent` retry POST/PATCH. + x-fern-retries: + max_attempts: 3 + base_delay_ms: 1 + factor: 1.0 + jitter: 0.0 + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Created user + + /users/{user_id}: + get: + x-fern-sdk-group-name: + - users + x-fern-sdk-method-name: get + operationId: users_get + summary: Get a user by ID + parameters: + - name: user_id + in: path + required: true + schema: + type: string + - name: legacy_flag + in: query + description: Old flag retained server-side but hidden from the CLI surface. + x-fern-ignore: true + schema: + type: string + responses: + "200": + description: User object + delete: + x-fern-sdk-group-name: + - users + x-fern-sdk-method-name: hardDelete + operationId: users_hardDelete + summary: (Hidden) Hard-delete a user. + x-fern-ignore: true + parameters: + - name: user_id + in: path + required: true + schema: + type: string + responses: + "204": + description: Deleted + + # --- files --- + + /files/upload: + post: + x-fern-sdk-group-name: + - files + x-fern-sdk-method-name: upload + operationId: files_upload + summary: Upload a binary file + description: | + Exercises the binary-body code path. The CLI exposes a `--file` flag + for ``, `@`, and `-` (stdin). Used by the wire test that + verifies disk paths emit `Content-Length` and stdin emits + `Transfer-Encoding: chunked`. + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + "200": + description: Upload accepted + + /files/{file_id}: + get: + x-fern-sdk-group-name: + - files + x-fern-sdk-method-name: get + operationId: files_get + summary: Get a file by ID + parameters: + - name: file_id + in: path + required: true + schema: + type: string + responses: + "200": + description: File object + put: + x-fern-sdk-group-name: + - files + x-fern-sdk-method-name: update + operationId: files_update + summary: Update a file + parameters: + - name: file_id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + "200": + description: Updated file + delete: + x-fern-sdk-group-name: + - files + x-fern-sdk-method-name: delete + operationId: files_delete + summary: Delete a file + parameters: + - name: file_id + in: path + required: true + schema: + type: string + responses: + "204": + description: Deleted + + /files/{file_id}/copy: + post: + x-fern-sdk-group-name: + - files + x-fern-sdk-method-name: copy + operationId: files_copy + summary: Copy a file + parameters: + - name: file_id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + parent: + type: object + properties: + id: + type: string + responses: + "201": + description: Copied file + + /files/{file_id}/thumbnail: + get: + x-fern-sdk-group-name: + - files + x-fern-sdk-method-name: getThumbnail + operationId: files_getThumbnail + summary: Get a file thumbnail + parameters: + - name: file_id + in: path + required: true + schema: + type: string + responses: + "200": + description: Thumbnail image + + # --- folders --- + + /folders: + post: + x-fern-sdk-group-name: + - folders + x-fern-sdk-method-name: create + operationId: folders_create + summary: Create a folder + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + parent: + type: object + properties: + id: + type: string + responses: + "201": + description: Created folder + + /folders/{folder_id}: + get: + x-fern-sdk-group-name: + - folders + x-fern-sdk-method-name: get + operationId: folders_get + summary: Get a folder by ID + parameters: + - name: folder_id + in: path + required: true + schema: + type: string + responses: + "200": + description: Folder object + put: + x-fern-sdk-group-name: + - folders + x-fern-sdk-method-name: update + operationId: folders_update + summary: Update a folder + parameters: + - name: folder_id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + "200": + description: Updated folder + delete: + x-fern-sdk-group-name: + - folders + x-fern-sdk-method-name: delete + operationId: folders_delete + summary: Delete a folder + parameters: + - name: folder_id + in: path + required: true + schema: + type: string + responses: + "204": + description: Deleted + + /folders/{folder_id}/items: + get: + x-fern-sdk-group-name: + - folders + x-fern-sdk-method-name: listItems + operationId: folders_listItems + summary: List items in a folder + parameters: + - name: folder_id + in: path + required: true + schema: + type: string + responses: + "200": + description: Folder item list + + /folders/{folder_id}/copy: + post: + x-fern-sdk-group-name: + - folders + x-fern-sdk-method-name: copy + operationId: folders_copy + summary: Copy a folder + parameters: + - name: folder_id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + responses: + "201": + description: Copied folder + + # --- events: exercises explicit per-operation `x-fern-pagination` + # so the executor uses the per-op config rather than the heuristic. + /events: + get: + x-fern-sdk-group-name: + - events + x-fern-sdk-method-name: list + operationId: events_list + summary: List paginated events + x-fern-pagination: + cursor: $request.next_marker + next_cursor: $response.next_marker + results: $response.entries + parameters: + - name: next_marker + in: query + schema: + type: string + responses: + "200": + description: Paginated event list + + # --- audit: exercises offset-form `x-fern-pagination` with a + # `step: $request.limit` reference. The executor must advance the + # outgoing `offset` query param by the caller's `--limit` value, not + # by the number of results returned on the page. + /audit: + get: + x-fern-sdk-group-name: + - audit + x-fern-sdk-method-name: list + operationId: audit_list + summary: List audit entries (offset-paginated) + x-fern-pagination: + offset: $request.offset + results: $response.entries + step: $request.limit + parameters: + - name: offset + in: query + schema: + type: integer + - name: limit + in: query + schema: + type: integer + responses: + "200": + description: Paginated audit list + + # --- idempotency --- + # Both endpoints share a path and a group. The idempotent `create` op + # surfaces an `--idempotency-key` flag (synthesized from the spec-root + # `x-fern-idempotency-headers` list) and sends `Idempotency-Key` on + # the wire; the non-idempotent `list` sibling does not. + + /payments: + get: + x-fern-sdk-group-name: + - payments + x-fern-sdk-method-name: list + operationId: payments_list + summary: List payments (non-idempotent) + responses: + "200": + description: Paginated payment list + post: + x-fern-sdk-group-name: + - payments + x-fern-sdk-method-name: create + operationId: payments_create + summary: Create a payment (idempotent) + x-fern-idempotent: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + amount: + type: integer + currency: + type: string + responses: + "201": + description: Created payment + + # --- availability badges --- + # Endpoints under /experiments exercise x-fern-availability + the standard + # OpenAPI deprecated:true fallback. Help output should attach the matching + # [BETA] / [PRE-RELEASE] / [DEPRECATED] badge to the command summary. + + /experiments/beta: + get: + x-fern-sdk-group-name: + - experiments + x-fern-sdk-method-name: beta-op + x-fern-availability: beta + operationId: experiments_beta + summary: Beta operation + responses: + "200": + description: ok + + /experiments/pre-release: + get: + x-fern-sdk-group-name: + - experiments + x-fern-sdk-method-name: pre-release-op + x-fern-availability: pre-release + operationId: experiments_preRelease + summary: Pre-release operation + responses: + "200": + description: ok + + /experiments/ga: + get: + x-fern-sdk-group-name: + - experiments + x-fern-sdk-method-name: ga-op + x-fern-availability: ga + operationId: experiments_ga + summary: Generally-available operation (alias) — should NOT carry a badge + responses: + "200": + description: ok + + /experiments/deprecated: + get: + x-fern-sdk-group-name: + - experiments + x-fern-sdk-method-name: deprecated-op + x-fern-availability: deprecated + operationId: experiments_deprecated + summary: Deprecated operation — still callable + parameters: + - name: legacy_flag + in: query + description: A flag that itself is marked beta to verify per-parameter badges. + x-fern-availability: beta + schema: + type: string + responses: + "200": + description: ok + + /experiments/openapi-deprecated: + get: + x-fern-sdk-group-name: + - experiments + x-fern-sdk-method-name: openapi-deprecated-op + deprecated: true + operationId: experiments_openapiDeprecated + summary: Op marked deprecated with OpenAPI's standard flag (no extension) + responses: + "200": + description: ok + + # --- search --- + + /search: + get: + x-fern-sdk-group-name: + - search + x-fern-sdk-method-name: query + operationId: search_query + summary: Search with deep object filter + parameters: + - name: filter + in: query + style: deepObject + explode: true + schema: + type: object + responses: + "200": + description: Search results + + # --- reports: exercises `x-fern-sdk-return-value`. The server returns a + # wrapper envelope `{ data: [...], meta: {...} }`; the CLI should + # surface only `data` by default and the full envelope under + # `--no-extract`. Nested path lookup is covered by `getStats`, which + # extracts `result.payload` from a two-level wrapper. `listPaged` + # composes the extension with `x-fern-pagination` so each page's + # extracted subvalue is emitted while continuation still reads the + # envelope-level `next` cursor (a field outside the extracted + # subvalue, exactly the case the pagination + return-value contract + # has to handle). + /reports: + get: + x-fern-sdk-group-name: + - reports + x-fern-sdk-method-name: list + operationId: reports_list + summary: List reports (envelope-wrapped) + x-fern-sdk-return-value: data + responses: + "200": + description: Envelope with data + meta + content: + application/json: + schema: + type: object + required: [data, meta] + properties: + data: + type: array + items: + type: object + required: [id, name] + properties: + id: { type: string } + name: { type: string } + meta: + type: object + properties: + total: { type: integer } + page: { type: integer } + + /reports/stats: + get: + x-fern-sdk-group-name: + - reports + x-fern-sdk-method-name: getStats + operationId: reports_getStats + summary: Read a nested return value + x-fern-sdk-return-value: result.payload + responses: + "200": + description: Two-level wrapper response + content: + application/json: + schema: + type: object + required: [result] + properties: + result: + type: object + properties: + payload: + type: object + properties: + value: { type: integer } + unit: { type: string } + meta: + type: object + properties: + server_time: { type: string } + + /reports/paged: + get: + x-fern-sdk-group-name: + - reports + x-fern-sdk-method-name: listPaged + operationId: reports_listPaged + summary: Cursor-paginated reports with envelope extraction + x-fern-sdk-return-value: data + x-fern-pagination: + cursor: $request.cursor + next_cursor: $response.next + results: $response.data + parameters: + - name: cursor + in: query + schema: + type: string + responses: + "200": + description: Page of reports plus an envelope-level cursor + content: + application/json: + schema: + type: object + required: [data] + properties: + data: + type: array + items: + type: object + required: [id, name] + properties: + id: { type: string } + name: { type: string } + next: + type: string + + # Exercises `x-fern-sdk-variable`: the `gardenId` path parameter binds to + # the global `gardenId` variable declared at the spec root, so the CLI + # exposes `--garden-id` / $GARDEN_ID instead of a per-op flag. + /gardens/{gardenId}/zones: + get: + x-fern-sdk-group-name: + - zones + x-fern-sdk-method-name: list + operationId: zones_list + summary: List zones in a garden (variable-bound path param). + parameters: + - name: gardenId + in: path + required: true + x-fern-sdk-variable: gardenId + schema: + type: string + responses: + "200": + description: ok + + # x-fern-audiences fixtures (FER-9864 P2). Operations under a + # dedicated `audiences` group so the integration test surface stays + # self-contained. Mirrors fern's importer expectation that audiences + # are an array of strings on operation objects + # (`packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/operation/convertHttpOperation.ts:330`). + /audiences/public-only: + get: + x-fern-sdk-group-name: + - audiences + x-fern-sdk-method-name: public-only + operationId: audiences_public_only + summary: Op tagged with x-fern-audiences=[public]. + x-fern-audiences: + - public + responses: + "200": + description: ok + + /audiences/internal-only: + get: + x-fern-sdk-group-name: + - audiences + x-fern-sdk-method-name: internal-only + operationId: audiences_internal_only + summary: Op tagged with x-fern-audiences=[internal]. + x-fern-audiences: + - internal + responses: + "200": + description: ok + + /audiences/untagged: + get: + x-fern-sdk-group-name: + - audiences + x-fern-sdk-method-name: untagged + operationId: audiences_untagged + summary: Op with no x-fern-audiences extension. + responses: + "200": + description: ok + + /audiences/multi-tagged: + get: + x-fern-sdk-group-name: + - audiences + x-fern-sdk-method-name: multi-tagged + operationId: audiences_multi_tagged + summary: Op tagged with x-fern-audiences=[public, internal]. + x-fern-audiences: + - public + - internal + responses: + "200": + description: ok + + # --- things --- + # An endpoint with a body schema covering every type the executor coerces. + # Used by `tests/openapi_fixture_wire.rs` to verify per-field body flags. + /things: + post: + x-fern-sdk-group-name: + - things + x-fern-sdk-method-name: create + operationId: things_create + summary: Create a thing + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + count: + type: integer + is_active: + type: boolean + tags: + type: array + items: + type: string + metadata: + type: object + responses: + "201": + description: Created thing + + # --- persons --- + # Endpoint with a nested object body (depth 1). Exercises flatten_body_params + # so --name.first / --name.last reconstruct {"name":{"first":"...","last":"..."}}. + /persons: + post: + x-fern-sdk-group-name: + - persons + x-fern-sdk-method-name: create + operationId: persons_create + summary: Create a person (nested body) + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: object + properties: + first: + type: string + last: + type: string + role: + type: string + responses: + "201": + description: Created person + + # --- articles --- + # Endpoint with an array body field. Exercises repeated:true so + # --tag admin --tag reviewer accumulates into ["admin","reviewer"]. + /articles: + post: + x-fern-sdk-group-name: + - articles + x-fern-sdk-method-name: create + operationId: articles_create + summary: Create an article (array body field) + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + title: + type: string + tag: + type: array + items: + type: string + responses: + "201": + description: Created article + + # --- widgets --- + # Endpoint with a $ref request body — exercises $ref resolution in + # flatten_body_params so the NewWidget schema properties are flattened. + /widgets: + post: + x-fern-sdk-group-name: + - widgets + x-fern-sdk-method-name: create + operationId: widgets_create + summary: Create a widget ($ref body) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewWidget' + responses: + "201": + description: Created widget + + # --- orders --- + # Endpoint with an inline body schema that contains a $ref property. + # Exercises $ref resolution within flatten_body_params_prefix so + # --address.city / --address.zip reconstruct {"address":{"city":"...","zip":"..."}}. + /orders: + post: + x-fern-sdk-group-name: + - orders + x-fern-sdk-method-name: create + operationId: orders_create + summary: Create an order ($ref property within inline schema) + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + note: + type: string + address: + $ref: '#/components/schemas/Address' + responses: + "201": + description: Created order + + # --- messages (form-urlencoded) --- + + /messages: + post: + x-fern-sdk-group-name: + - messages + x-fern-sdk-method-name: send + operationId: messages_send + summary: Send a message (form-urlencoded body) + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: + - To + - From + - Body + properties: + To: + type: string + From: + type: string + Body: + type: string + Priority: + type: integer + MediaUrl: + type: array + items: + type: string + responses: + "201": + description: Message sent + + /messages/ref-body: + post: + x-fern-sdk-group-name: + - messages + x-fern-sdk-method-name: sendRef + operationId: messages_sendRef + summary: Send a message (form-urlencoded with $ref schema) + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/MessageRequest' + responses: + "201": + description: Message sent + +components: + schemas: + NewWidget: + type: object + properties: + label: + type: string + priority: + type: integer + Address: + type: object + properties: + city: + type: string + zip: + type: string + MessageRequest: + type: object + required: + - To + - Body + properties: + To: + type: string + From: + type: string + Body: + type: string diff --git a/generators/cli/sdk/src/app.rs b/generators/cli/sdk/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/generators/cli/sdk/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/generators/cli/sdk/src/arg_source.rs b/generators/cli/sdk/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/generators/cli/sdk/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/generators/cli/sdk/src/auth/builder.rs b/generators/cli/sdk/src/auth/builder.rs index 5ab6fcb1a66f..e629dd01553d 100644 --- a/generators/cli/sdk/src/auth/builder.rs +++ b/generators/cli/sdk/src/auth/builder.rs @@ -48,17 +48,6 @@ pub enum SchemeBinding { username: AuthCredentialSource, password: AuthCredentialSource, }, - /// Single-value source bound to the *username* half of http basic; - /// the password is sent as the empty string. Common for APIs that - /// accept an API key in the basic-auth username slot. Lowers to - /// [`BasicAuthProvider::username_only`], whose `has_credentials()` - /// only requires the username to resolve. - BasicUsernameOnly(AuthCredentialSource), - /// Single-value source bound to the *password* half of http basic; - /// the username is sent as the empty string. Symmetric counterpart - /// to [`SchemeBinding::BasicUsernameOnly`]. Lowers to - /// [`BasicAuthProvider::password_only`]. - BasicPasswordOnly(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`]. @@ -70,8 +59,6 @@ impl std::fmt::Debug for SchemeBinding { match self { SchemeBinding::Token(s) => f.debug_tuple("Token").field(s).finish(), SchemeBinding::Basic { .. } => f.write_str("Basic { .. }"), - SchemeBinding::BasicUsernameOnly(_) => f.write_str("BasicUsernameOnly { .. }"), - SchemeBinding::BasicPasswordOnly(_) => f.write_str("BasicPasswordOnly { .. }"), SchemeBinding::Custom(p) => write!(f, "Custom({})", p.name()), } } @@ -90,9 +77,6 @@ impl SchemeBinding { out.extend(password.cli_args()); out } - SchemeBinding::BasicUsernameOnly(src) | SchemeBinding::BasicPasswordOnly(src) => { - src.cli_args() - } SchemeBinding::Custom(_) => Vec::new(), } } @@ -108,12 +92,6 @@ impl SchemeBinding { username: username.finalize(matches), password: password.finalize(matches), }, - SchemeBinding::BasicUsernameOnly(src) => { - SchemeBinding::BasicUsernameOnly(src.finalize(matches)) - } - SchemeBinding::BasicPasswordOnly(src) => { - SchemeBinding::BasicPasswordOnly(src.finalize(matches)) - } SchemeBinding::Custom(p) => SchemeBinding::Custom(p), } } @@ -167,18 +145,6 @@ fn describe_binding_sources(binding: &SchemeBinding) -> String { describe_credential_source(password), ) } - SchemeBinding::BasicUsernameOnly(src) => { - format!( - "basic auth (username only) · username: {}", - describe_credential_source(src), - ) - } - SchemeBinding::BasicPasswordOnly(src) => { - format!( - "basic auth (password only) · password: {}", - describe_credential_source(src), - ) - } SchemeBinding::Custom(_) => "custom auth provider".to_string(), } } @@ -297,32 +263,6 @@ fn provider_for_binding( None } }, - SchemeBinding::BasicUsernameOnly(src) => match declared { - Some(S::HttpBasic) | None => Some(Arc::new(BasicAuthProvider::username_only( - scheme_name, - src.clone(), - ))), - _ => { - tracing::warn!( - scheme = scheme_name, - "auth_basic_scheme_username_only: scheme is not HTTP Basic; binding ignored", - ); - None - } - }, - SchemeBinding::BasicPasswordOnly(src) => match declared { - Some(S::HttpBasic) | None => Some(Arc::new(BasicAuthProvider::password_only( - scheme_name, - src.clone(), - ))), - _ => { - tracing::warn!( - scheme = scheme_name, - "auth_basic_scheme_password_only: scheme is not HTTP Basic; binding ignored", - ); - None - } - }, } } @@ -562,60 +502,6 @@ mod tests { ); } - #[tokio::test] - async fn basic_username_only_binding_sends_authorization_with_empty_password() { - // The Close pattern: API key in the username slot, password - // unused. Previously expressed as `Basic { from_env, literal("") }`, - // which silently dropped the header because `literal("")` resolves - // to `None`. The specialized binding lowers to - // `BasicAuthProvider::username_only`, which only requires the - // username to resolve. - let doc = doc_with_schemes(&[( - "basic", - crate::openapi::discovery::SecurityScheme::HttpBasic, - )]); - let bindings = vec![( - "basic".to_string(), - SchemeBinding::BasicUsernameOnly(AuthCredentialSource::literal("api_key_123")), - )]; - let p = build_provider_from_doc(&doc, &bindings); - assert!(p.has_credentials()); - let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); - // base64("api_key_123:") = "YXBpX2tleV8xMjM6" - assert_eq!(auth_header(r).as_deref(), Some("Basic YXBpX2tleV8xMjM6")); - } - - #[tokio::test] - async fn basic_password_only_binding_sends_authorization_with_empty_username() { - let doc = doc_with_schemes(&[( - "basic", - crate::openapi::discovery::SecurityScheme::HttpBasic, - )]); - let bindings = vec![( - "basic".to_string(), - SchemeBinding::BasicPasswordOnly(AuthCredentialSource::literal("the_secret")), - )]; - let p = build_provider_from_doc(&doc, &bindings); - assert!(p.has_credentials()); - let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); - // base64(":the_secret") = "OnRoZV9zZWNyZXQ=" - assert_eq!(auth_header(r).as_deref(), Some("Basic OnRoZV9zZWNyZXQ=")); - } - - #[test] - fn basic_username_only_against_non_basic_scheme_is_skipped() { - let doc = doc_with_schemes(&[( - "bearerAuth", - crate::openapi::discovery::SecurityScheme::HttpBearer, - )]); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::BasicUsernameOnly(AuthCredentialSource::literal("oops")), - )]; - let p = build_provider_from_doc(&doc, &bindings); - assert!(!p.has_credentials()); - } - #[test] fn token_binding_for_basic_scheme_is_skipped() { // Token form can't satisfy HttpBasic (which needs two values). @@ -971,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/generators/cli/sdk/src/auth/mod.rs b/generators/cli/sdk/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/generators/cli/sdk/src/auth/mod.rs +++ b/generators/cli/sdk/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/generators/cli/sdk/src/auth/root_builder.rs b/generators/cli/sdk/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/generators/cli/sdk/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/generators/cli/sdk/src/binding.rs b/generators/cli/sdk/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/generators/cli/sdk/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/generators/cli/sdk/src/cli_args.rs b/generators/cli/sdk/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/generators/cli/sdk/src/cli_args.rs +++ b/generators/cli/sdk/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/generators/cli/sdk/src/completions.rs b/generators/cli/sdk/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/generators/cli/sdk/src/completions.rs +++ b/generators/cli/sdk/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/generators/cli/sdk/src/custom_commands.rs b/generators/cli/sdk/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/generators/cli/sdk/src/custom_commands.rs +++ b/generators/cli/sdk/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/generators/cli/sdk/src/early_intercept.rs b/generators/cli/sdk/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/generators/cli/sdk/src/early_intercept.rs +++ b/generators/cli/sdk/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/generators/cli/sdk/src/error.rs b/generators/cli/sdk/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/generators/cli/sdk/src/error.rs +++ b/generators/cli/sdk/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/generators/cli/sdk/src/formatter.rs b/generators/cli/sdk/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/generators/cli/sdk/src/formatter.rs +++ b/generators/cli/sdk/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/generators/cli/sdk/src/graphql/app.rs b/generators/cli/sdk/src/graphql/app.rs index e673f9dd13ab..b04c4a6cf262 100644 --- a/generators/cli/sdk/src/graphql/app.rs +++ b/generators/cli/sdk/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -113,38 +102,6 @@ impl CliApp { self } - /// Username-only basic auth (password sent as `""`). See - /// [`OpenApiCliApp::auth_basic_scheme_username_only`][a] for rationale. - /// - /// [a]: crate::openapi::CliApp::auth_basic_scheme_username_only - pub fn auth_basic_scheme_username_only( - mut self, - scheme_name: &str, - username: AuthCredentialSource, - ) -> Self { - self.auth_bindings.push(( - scheme_name.to_string(), - SchemeBinding::BasicUsernameOnly(username), - )); - self - } - - /// Password-only basic auth (username sent as `""`). See - /// [`OpenApiCliApp::auth_basic_scheme_password_only`][a] for rationale. - /// - /// [a]: crate::openapi::CliApp::auth_basic_scheme_password_only - pub fn auth_basic_scheme_password_only( - mut self, - scheme_name: &str, - password: AuthCredentialSource, - ) -> Self { - self.auth_bindings.push(( - scheme_name.to_string(), - SchemeBinding::BasicPasswordOnly(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 @@ -179,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -243,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } - } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + /// Decorate a clap `Command` with the auth help section. + /// 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); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + if let Some(ref s) = auth_section { + sections.push(s); } + cli = cli.after_help(sections.join("\n\n")); } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -487,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -509,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -542,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -597,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -624,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -665,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/generators/cli/sdk/src/graphql/binding.rs b/generators/cli/sdk/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/generators/cli/sdk/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/generators/cli/sdk/src/graphql/commands.rs b/generators/cli/sdk/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/generators/cli/sdk/src/graphql/commands.rs +++ b/generators/cli/sdk/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/generators/cli/sdk/src/graphql/mod.rs b/generators/cli/sdk/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/generators/cli/sdk/src/graphql/mod.rs +++ b/generators/cli/sdk/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/generators/cli/sdk/src/hooks.rs b/generators/cli/sdk/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/generators/cli/sdk/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/generators/cli/sdk/src/lib.rs b/generators/cli/sdk/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/generators/cli/sdk/src/lib.rs +++ b/generators/cli/sdk/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/generators/cli/sdk/src/logging.rs b/generators/cli/sdk/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/generators/cli/sdk/src/logging.rs +++ b/generators/cli/sdk/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/generators/cli/sdk/src/man.rs b/generators/cli/sdk/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/generators/cli/sdk/src/man.rs +++ b/generators/cli/sdk/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/generators/cli/sdk/src/openapi/__fixtures__/openapi.json b/generators/cli/sdk/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 9b465f33a3e9..000000000000 --- a/generators/cli/sdk/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Test Fixture API", - "version": "1.0.0" - }, - "paths": { - "/users": { - "get": { - "x-fern-sdk-group-name": ["users"], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "parameters": [ - { - "name": "limit", - "in": "query", - "schema": { "type": "integer" } - } - ], - "responses": { - "200": { "description": "OK" } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": ["files"], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { "description": "OK" } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": ["files"], - "x-fern-sdk-method-name": "thumbnail", - "operationId": "files_thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { "description": "OK" } - } - } - } - } -} diff --git a/generators/cli/sdk/src/openapi/__fixtures__/rich.json b/generators/cli/sdk/src/openapi/__fixtures__/rich.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/generators/cli/sdk/src/openapi/__fixtures__/rich.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/generators/cli/sdk/src/openapi/app.rs b/generators/cli/sdk/src/openapi/app.rs index cceff2719823..e0dcfb9e7deb 100644 --- a/generators/cli/sdk/src/openapi/app.rs +++ b/generators/cli/sdk/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1156,46 +983,6 @@ impl CliApp { self } - /// Bind a single source to the *username* half of an `http: basic` - /// scheme; password goes out as the empty string. Use for APIs that - /// expect the credential in the basic-auth username slot (Close, - /// Stripe-with-key-as-username, etc.). - /// - /// Equivalent to [`auth_basic_scheme`] with the password set to an - /// always-resolving zero-length source, but distinct because the - /// SDK's `has_credentials()` check only looks at the username here — - /// callers don't need to invent a sentinel for the unused half. - /// - /// [`auth_basic_scheme`]: Self::auth_basic_scheme - pub fn auth_basic_scheme_username_only( - mut self, - scheme_name: &str, - username: AuthCredentialSource, - ) -> Self { - self.auth_bindings.push(( - scheme_name.to_string(), - SchemeBinding::BasicUsernameOnly(username), - )); - self - } - - /// Symmetric counterpart to [`auth_basic_scheme_username_only`] — bind - /// a single source to the basic-auth password while the username goes - /// out empty. Used by APIs that put the token in the password slot. - /// - /// [`auth_basic_scheme_username_only`]: Self::auth_basic_scheme_username_only - pub fn auth_basic_scheme_password_only( - mut self, - scheme_name: &str, - password: AuthCredentialSource, - ) -> Self { - self.auth_bindings.push(( - scheme_name.to_string(), - SchemeBinding::BasicPasswordOnly(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 @@ -1245,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1290,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1309,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + 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) + .global(true) + .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()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1682,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1713,10 +1367,21 @@ impl AppContext { /// 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) @@ -1727,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1740,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1748,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1767,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1776,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1789,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1822,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1831,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1865,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1893,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1911,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2347,57 +2070,6 @@ mod tests { )); } - #[test] - fn test_auth_basic_scheme_username_only_records_specialized_binding() { - let app = CliApp::new("t") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .auth_basic_scheme_username_only("basic", AuthCredentialSource::from_env("KEY")); - assert!(matches!( - app.auth_bindings[0].1, - SchemeBinding::BasicUsernameOnly(_), - )); - } - - #[test] - fn test_auth_basic_scheme_password_only_records_specialized_binding() { - let app = CliApp::new("t") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .auth_basic_scheme_password_only("basic", AuthCredentialSource::from_env("KEY")); - assert!(matches!( - app.auth_bindings[0].1, - SchemeBinding::BasicPasswordOnly(_), - )); - } - - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2492,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2531,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2574,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2661,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2760,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3184,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3270,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/generators/cli/sdk/src/openapi/binding.rs b/generators/cli/sdk/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/generators/cli/sdk/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/generators/cli/sdk/src/openapi/commands.rs b/generators/cli/sdk/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/generators/cli/sdk/src/openapi/commands.rs +++ b/generators/cli/sdk/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/generators/cli/sdk/src/openapi/discovery.rs b/generators/cli/sdk/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/generators/cli/sdk/src/openapi/discovery.rs +++ b/generators/cli/sdk/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/generators/cli/sdk/src/openapi/executor.rs b/generators/cli/sdk/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/generators/cli/sdk/src/openapi/executor.rs +++ b/generators/cli/sdk/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/generators/cli/sdk/src/openapi/help.rs b/generators/cli/sdk/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/generators/cli/sdk/src/openapi/help.rs +++ b/generators/cli/sdk/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/generators/cli/sdk/src/openapi/mod.rs b/generators/cli/sdk/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/generators/cli/sdk/src/openapi/mod.rs +++ b/generators/cli/sdk/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/generators/cli/sdk/src/openapi/overlay.rs b/generators/cli/sdk/src/openapi/overlay.rs index d3b0f3cd72b0..85659b5da950 100644 --- a/generators/cli/sdk/src/openapi/overlay.rs +++ b/generators/cli/sdk/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1887,12 +1887,48 @@ actions: ); } - // (Previously: an integration smoke that exercised the rich - // template fixture's groups/methods after overlay. Coverage moved - // to `tests/cli_integration.rs` + `tests/openapi_fixture_wire.rs` - // — both of which exec the openapi-fixture bin against the rich - // fixture and assert deeper than this lib test ever could. The - // remaining `test_overlay_on_fixture_spec` above already covers - // the overlay→merge→build_doc lib path against the tiny shipped - // fixture.) + #[test] + fn test_overlay_on_fixture_spec_builds_cli_app() { + use crate::openapi::CliApp; + + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); + let overlay = r#" +overlay: "1.0.0" +info: + title: fixture-overlay + version: "1.0.0" +actions: + - target: "$.paths['/files/{file_id}/thumbnail']" + remove: true +"#; + + let app = CliApp::new("overlay-fixture") + .spec(spec) + .overlay(overlay); + let doc = app.build_doc().unwrap(); + + // files and folders groups should still exist + assert!(doc.resources.contains_key("files"), "files group missing"); + assert!(doc.resources.contains_key("folders"), "folders group missing"); + assert!(doc.resources.contains_key("users"), "users group missing"); + + // getThumbnail should be gone from the files resource + let files = &doc.resources["files"]; + assert!( + !files.methods.contains_key("getThumbnail"), + "getThumbnail should be removed: {:?}", + files.methods.keys().collect::>() + ); + // Other file operations should still exist + assert!( + files.methods.contains_key("get"), + "get should remain: {:?}", + files.methods.keys().collect::>() + ); + assert!( + files.methods.contains_key("update"), + "update should remain: {:?}", + files.methods.keys().collect::>() + ); + } } diff --git a/generators/cli/sdk/src/openapi/parser.rs b/generators/cli/sdk/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/generators/cli/sdk/src/openapi/parser.rs +++ b/generators/cli/sdk/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/generators/cli/sdk/src/openapi/skill_emitter.rs b/generators/cli/sdk/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/generators/cli/sdk/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/generators/cli/sdk/src/stability.rs b/generators/cli/sdk/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/generators/cli/sdk/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/generators/cli/sdk/tests/cli_integration.rs b/generators/cli/sdk/tests/cli_integration.rs deleted file mode 100644 index 91331b984046..000000000000 --- a/generators/cli/sdk/tests/cli_integration.rs +++ /dev/null @@ -1,1905 +0,0 @@ -use std::process::Command; - -fn fixture_cmd() -> Command { - Command::new(env!("CARGO_BIN_EXE_openapi-fixture")) -} - -// --------------------------------------------------------------------------- -// Wire-style integration tests (wiremock) -// -// Each test spins up an ephemeral mock HTTP server, points the CLI at it via -// OPENAPI_FIXTURE_BASE_URL, and verifies both the outgoing request shape and how the -// CLI handles the response. -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod wire { - use super::fixture_cmd; - use serde_json::json; - use std::process::Command; - use wiremock::matchers::{body_json, method, path, query_param}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - fn live_cmd(server: &MockServer) -> Command { - let mut cmd = fixture_cmd(); - cmd.env("OPENAPI_FIXTURE_BASE_URL", server.uri()) - .env("OPENAPI_FIXTURE_API_KEY", "test-token"); - cmd - } - - // --- GET with path params --- - - #[tokio::test] - async fn test_get_file_by_id_request_and_response() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/files/abc123")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "id": "abc123", - "type": "file", - "name": "quarterly-report.pdf" - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["files", "get", "--file-id", "abc123"]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("abc123"), "response id should appear in output"); - assert!(stdout.contains("quarterly-report.pdf"), "response name should appear in output"); - // wiremock verifies expect(1) on drop - } - - #[tokio::test] - async fn test_get_current_user() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users/me")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "id": "u-999", - "type": "user", - "name": "Dan Shipper", - "login": "dan@example.com" - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["users", "getCurrent"]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Dan Shipper")); - assert!(stdout.contains("dan@example.com")); - } - - // --- POST with body --- - - #[tokio::test] - async fn test_create_folder_sends_body() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/folders")) - .and(body_json(json!({ - "name": "my-test-folder", - "parent": { "id": "0" } - }))) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({ - "id": "folder-456", - "type": "folder", - "name": "my-test-folder" - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "folders", - "create", - "--json", - r#"{"name":"my-test-folder","parent":{"id":"0"}}"#, - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("folder-456")); - assert!(stdout.contains("my-test-folder")); - } - - // --- Query params --- - - #[tokio::test] - async fn test_list_users_query_param_forwarded() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users")) - .and(query_param("filter_term", "alice")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "total_count": 1, - "entries": [{ "id": "u1", "type": "user", "name": "Alice Smith" }] - })), - ) - .expect(1) - .mount(&server) - .await; - - // The fixture aliases `filter_term` to `searchQuery` via - // `x-fern-parameter-name`, so the CLI flag is `--search-query` - // even though the wire param is still `filter_term`. - let output = live_cmd(&server) - .args(["users", "list", "--search-query", "alice"]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Alice Smith")); - } - - // --- Error responses --- - - #[tokio::test] - async fn test_404_exits_nonzero_with_error_details() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/files/does-not-exist")) - .respond_with(ResponseTemplate::new(404).set_body_json(json!({ - "error": { - "code": 404, - "message": "Item not found", - "errors": [{ "reason": "notFound" }] - } - }))) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["files", "get", "--file-id", "does-not-exist"]) - .output() - .unwrap(); - - assert!(!output.status.success(), "CLI should exit non-zero on 404"); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("notFound") || stdout.contains("not found") || stdout.contains("404"), - "error reason should appear in output, got: {stdout}" - ); - } - - #[tokio::test] - async fn test_500_exits_nonzero() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users/me")) - .respond_with(ResponseTemplate::new(500).set_body_json(json!({ - "error": { - "code": 500, - "message": "Internal Server Error", - "errors": [{ "reason": "internalError" }] - } - }))) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["users", "getCurrent"]) - .output() - .unwrap(); - - assert!(!output.status.success(), "CLI should exit non-zero on 500"); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("500") || stdout.contains("internalError"), - "error code should appear in output, got: {stdout}" - ); - } - - // --- Pagination --- - - #[tokio::test] - async fn test_page_all_follows_next_page_token() { - let server = MockServer::start().await; - - // Page 1: no marker in query → return entries + nextPageToken - Mock::given(method("GET")) - .and(path("/users")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "total_count": 2, - "entries": [{ "id": "u1", "type": "user", "name": "Alice" }], - "nextPageToken": "cursor-page-2" - })), - ) - .up_to_n_times(1) - .mount(&server) - .await; - - // Page 2: cursor present → return final entries, no token - Mock::given(method("GET")) - .and(path("/users")) - .and(query_param("pageToken", "cursor-page-2")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "total_count": 2, - "entries": [{ "id": "u2", "type": "user", "name": "Bob" }] - })), - ) - .up_to_n_times(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["users", "list", "--page-all"]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Alice"), "first page results should appear"); - assert!(stdout.contains("Bob"), "second page results should appear"); - } -} - -// --------------------------------------------------------------------------- -// Help / command-registration tests -// --------------------------------------------------------------------------- - -#[test] -fn test_help_shows_all_groups() { - let output = fixture_cmd().arg("--help").output().unwrap(); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let combined = format!("{stdout}{stderr}"); - - for group in ["files", "folders", "users"] { - assert!(combined.contains(group), "Missing group: {group}"); - } -} - -#[test] -fn test_files_help_shows_methods() { - let output = fixture_cmd() - .args(["files", "--help"]) - .output() - .unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - let expected = ["get", "update", "delete", "copy", "getThumbnail"]; - for method in expected { - assert!(combined.contains(method), "Missing method: {method}"); - } -} - -#[test] -fn test_users_help_shows_methods() { - let output = fixture_cmd() - .args(["users", "--help"]) - .output() - .unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!(combined.contains("getCurrent")); - assert!(combined.contains("list")); - assert!(combined.contains("get")); - assert!(combined.contains("create")); -} - -// --------------------------------------------------------------------------- -// x-fern-groups (FER-9864 P3) — document-root metadata that re-labels -// group subcommands in `--help`. The fixture's `x-fern-groups` block -// annotates `users` and `files` (one with description, one without) and -// intentionally omits `events` so the missing-metadata fallback is also -// exercised end-to-end against the built binary. -// --------------------------------------------------------------------------- - -#[test] -fn test_x_fern_groups_summary_appears_in_root_help() { - // The root `--help` lists each top-level subcommand next to its - // `about()` line. With `x-fern-groups.users.summary` set to - // `Users Operations`, that label must replace the legacy - // `Operations on 'users'` fallback. - let output = fixture_cmd().arg("--help").output().unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - assert!( - combined.contains("Users Operations"), - "`x-fern-groups.users.summary` should surface in root --help, got:\n{combined}", - ); - assert!( - combined.contains("Files Operations"), - "`x-fern-groups.files.summary` should surface in root --help, got:\n{combined}", - ); - // The legacy fallback label for annotated groups must NOT appear — - // the summary is supposed to replace it. - assert!( - !combined.contains("Operations on 'users'"), - "legacy `Operations on 'users'` label should be replaced by the `x-fern-groups` summary, \ - got:\n{combined}", - ); -} - -#[test] -fn test_x_fern_groups_summary_appears_in_short_group_help() { - // `users -h` (short help) renders the `about()` line at the top. - // clap's contract: `-h` prefers `about`, `--help` prefers - // `long_about` whenever both are set. Picking `-h` here keeps the - // assertion focused on the `summary` surface specifically. - let output = fixture_cmd().args(["users", "-h"]).output().unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - assert!( - combined.contains("Users Operations"), - "`users -h` should show the `x-fern-groups` summary, got:\n{combined}", - ); -} - -#[test] -fn test_x_fern_groups_description_appears_in_long_help() { - // `users --help` (long help) prefers the `long_about` line set - // from `x-fern-groups.users.description`. The short `summary` is - // suppressed by clap in long help when `long_about` is set; this - // mirrors the contract `-h` ↔ `about`, `--help` ↔ `long_about`. - let output = fixture_cmd().args(["users", "--help"]).output().unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - assert!( - combined.contains("Manage users"), - "`users --help` should render the `x-fern-groups` description, got:\n{combined}", - ); -} - -#[test] -fn test_x_fern_groups_summary_appears_when_description_absent() { - // `files` has `summary` but no `description` in `x-fern-groups`. - // Both `files -h` and `files --help` should fall back to the - // `summary` (since clap falls back to `about` whenever - // `long_about` is unset). Asserts the description-optional - // pathway end-to-end. - let output = fixture_cmd().args(["files", "--help"]).output().unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - assert!( - combined.contains("Files Operations"), - "`files --help` should fall back to the `x-fern-groups` summary when no description is set, \ - got:\n{combined}", - ); -} - -#[test] -fn test_x_fern_groups_missing_entry_falls_back_to_default_label() { - // The fixture omits `events` from `x-fern-groups`. The group must - // still appear in `--help` with the legacy `Operations on - // 'events'` label — confirming the runtime survives a one-sided - // `x-fern-sdk-group-name` ↔ `x-fern-groups` configuration without - // crashing and without leaking placeholders. - let output = fixture_cmd().args(["events", "--help"]).output().unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - assert!( - output.status.success(), - "`events --help` must succeed even without a matching x-fern-groups entry: \ - status={:?}, output=\n{combined}", - output.status, - ); - assert!( - combined.contains("Operations on 'events'"), - "`events --help` should keep the legacy fallback label, got:\n{combined}", - ); -} - -#[test] -fn test_users_help_does_not_list_x_fern_ignore_op() { - // The fixture spec marks `DELETE /users/{user_id}` (method `hardDelete`) - // with `x-fern-ignore: true`. It must not be listed under `users --help`. - let output = fixture_cmd() - .args(["users", "--help"]) - .output() - .unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - !combined.contains("hardDelete"), - "ignored op `hardDelete` should not appear in `users --help`: {combined}" - ); -} - -#[test] -fn test_users_list_help_shows_x_fern_parameter_name_alias() { - // The fixture spec aliases the `filter_term` query param to - // `searchQuery` and the `X-Fern-Version` header to `version` via - // `x-fern-parameter-name`. The CLI `--help` for `users list` must - // surface the aliased (kebab-cased) flag names, and must NOT show - // the original wire-name flags. The wire names are still mentioned - // in the help description so users can correlate the flag with the - // upstream API doc / `--params` JSON. - let output = fixture_cmd() - .args(["users", "list", "--help"]) - .output() - .unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - // Aliased flag names appear on the CLI surface. - assert!( - combined.contains("--search-query"), - "aliased flag `--search-query` (filter_term → searchQuery) should be listed: {combined}" - ); - assert!( - combined.contains("--api-version"), - "aliased flag `--api-version` (X-Fern-Version → apiVersion) should be listed: {combined}" - ); - - // The original wire-name flags must NOT be present on the CLI: an - // un-aliased fallback would have produced `--filter-term` or - // `--x-fern-version` (kebab of the wire names). - assert!( - !combined.contains("--filter-term"), - "original wire-name flag `--filter-term` should not appear once aliased: {combined}" - ); - assert!( - !combined.contains("--x-fern-version"), - "original wire-name flag `--x-fern-version` should not appear once aliased: {combined}" - ); - - // The original wire name is surfaced in the help description so - // operators can still find the underlying API parameter. - assert!( - combined.contains("filter_term"), - "help description should mention wire name `filter_term`: {combined}" - ); - assert!( - combined.contains("X-Fern-Version"), - "help description should mention wire name `X-Fern-Version`: {combined}" - ); -} - -#[test] -fn test_users_get_help_does_not_list_x_fern_ignore_param() { - // `users get` has a `legacy_flag` parameter marked `x-fern-ignore: true`. - // It must not appear as a CLI flag in the operation's `--help` output. - let output = fixture_cmd() - .args(["users", "get", "--help"]) - .output() - .unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - !combined.contains("legacy-flag") && !combined.contains("legacy_flag"), - "ignored parameter should not appear in `users get --help`: {combined}" - ); - // Sanity: the non-ignored required `--user-id` flag still appears. - assert!( - combined.contains("user-id"), - "non-ignored `--user-id` flag should still be listed: {combined}" - ); -} - -// --------------------------------------------------------------------------- -// x-fern-global-headers (FER-9864 P2) — `--api-stage` registered globally -// with an env-var fallback, surfaced in root help only. -// --------------------------------------------------------------------------- - -#[test] -fn test_global_header_flag_appears_in_root_help_with_env_hint() { - // `x-fern-global-headers` on the fixture spec declares: - // - X-API-Stage (name: apiStage, env: FIXTURE_API_STAGE, default: production) - // - X-Tenant-Id (name: tenantId, optional, no env, no default) - // Root `--help` must surface both flags with the env / default hints. - let output = fixture_cmd().arg("--help").output().unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - combined.contains("--api-stage"), - "global header flag `--api-stage` (from name: apiStage) must appear in root help: {combined}" - ); - assert!( - combined.contains("FIXTURE_API_STAGE"), - "env-var fallback must be surfaced in root help: {combined}" - ); - assert!( - combined.contains("--tenant-id"), - "optional global header `--tenant-id` (from name: tenantId) must appear in root help: {combined}" - ); - // Help section column alignment: the two flag prefixes must share - // a common column for the help text. Pin the alignment so we don't - // regress back to the ragged layout that prompted the fix. - let api_stage_col = combined - .lines() - .find(|l| l.contains("--api-stage ")) - .and_then(|l| l.find("Global header")) - .expect("--api-stage row must be in help"); - let tenant_id_col = combined - .lines() - .find(|l| l.contains("--tenant-id ")) - .and_then(|l| l.find("Global header")) - .expect("--tenant-id row must be in help"); - assert_eq!( - api_stage_col, tenant_id_col, - "global-header help rows must be column-aligned (found api-stage@{api_stage_col}, tenant-id@{tenant_id_col})" - ); -} - -#[test] -fn test_global_header_flag_is_not_listed_on_per_operation_help() { - // Per the FER-9864 spec, global headers are root-level constructor - // flags; surfacing them on every per-op `--help` is noise. The flag - // is still parseable on operations (it's `.global(true)`), but the - // help renderer must omit it from the per-op flag list. - let output = fixture_cmd() - .args(["users", "getCurrent", "--help"]) - .output() - .unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - !combined.contains("--api-stage"), - "global header `--api-stage` must NOT be listed on per-op help: {combined}" - ); - assert!( - !combined.contains("--tenant-id"), - "global header `--tenant-id` must NOT be listed on per-op help: {combined}" - ); -} - -#[test] -fn test_global_header_default_drives_dry_run_when_flag_and_env_absent() { - // No `--api-stage`, no `$FIXTURE_API_STAGE` → the spec-baked - // default ("production") satisfies the required-header check and - // shows up nowhere visible to the user, but the command succeeds. - // The dry-run output proves we got past validation. - let output = fixture_cmd() - .args(["users", "getCurrent", "--dry-run"]) - .env_remove("FIXTURE_API_STAGE") - .output() - .unwrap(); - assert!( - output.status.success(), - "default value should satisfy required global header. stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("\"dry_run\": true")); -} - -#[test] -fn test_folders_help_shows_methods() { - let output = fixture_cmd() - .args(["folders", "--help"]) - .output() - .unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - let expected = ["get", "update", "delete", "listItems", "create", "copy"]; - for method in expected { - assert!(combined.contains(method), "Missing method: {method}"); - } -} - -// --------------------------------------------------------------------------- -// Dry-run tests -// --------------------------------------------------------------------------- - -#[test] -fn test_dry_run_get_current_user() { - let output = fixture_cmd() - .args(["users", "getCurrent", "--dry-run"]) - .output() - .unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("\"dry_run\": true")); - assert!(stdout.contains("/users/me")); - assert!(stdout.contains("\"method\": \"GET\"")); -} - -#[test] -fn test_dry_run_with_path_params() { - let output = fixture_cmd() - .args([ - "files", - "get", - "--params", - r#"{"file_id":"test-file-123"}"#, - "--dry-run", - ]) - .output() - .unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("\"dry_run\": true")); - assert!(stdout.contains("test-file-123") || stdout.contains("test%2Dfile%2D123")); - assert!(stdout.contains("\"method\": \"GET\"")); -} - -#[test] -fn test_dry_run_list_with_query_params() { - let output = fixture_cmd() - .args([ - "users", - "list", - "--params", - r#"{"filter_term":"alice"}"#, - "--dry-run", - ]) - .output() - .unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("\"dry_run\": true")); - assert!(stdout.contains("/users")); - assert!(stdout.contains("query_params")); -} - -#[test] -fn test_dry_run_post_with_body() { - let output = fixture_cmd() - .args([ - "folders", - "create", - "--json", - r#"{"name":"my-folder","parent":{"id":"0"}}"#, - "--dry-run", - ]) - .output() - .unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("\"method\": \"POST\"")); - assert!(stdout.contains("\"dry_run\": true")); -} - -// --------------------------------------------------------------------------- -// Typed flags tests -// --------------------------------------------------------------------------- - -#[test] -fn test_dry_run_with_individual_flags() { - let output = fixture_cmd() - .args(["files", "get", "--file-id", "test-file-123", "--dry-run"]) - .output() - .unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("\"dry_run\": true")); - assert!(stdout.contains("\"method\": \"GET\"")); - assert!( - stdout.contains("test-file-123") || stdout.contains("test%2Dfile%2D123"), - "URL should contain the file_id" - ); -} - -#[test] -fn test_dry_run_flags_with_enum_value() { - let output = fixture_cmd() - .args([ - "users", - "list", - "--user-type", - "managed", - "--dry-run", - ]) - .output() - .unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("managed")); -} - -#[test] -fn test_params_override_wins_over_flags() { - let output = fixture_cmd() - .args([ - "files", - "get", - "--file-id", - "from-flag", - "--params", - r#"{"file_id":"from-json"}"#, - "--dry-run", - ]) - .output() - .unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("from-json") || stdout.contains("from%2Djson"), - "--params should override individual flags" - ); -} - -#[test] -fn test_help_shows_parameter_flags() { - let output = fixture_cmd() - .args(["files", "get", "--help"]) - .output() - .unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!(combined.contains("--file-id"), "Help should show --file-id flag"); -} - -#[test] -fn test_list_method_shows_query_flags() { - let output = fixture_cmd() - .args(["users", "list", "--help"]) - .output() - .unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - // `filter_term` is exposed via the `x-fern-parameter-name` alias - // `searchQuery`, so the kebab-cased CLI flag is `--search-query`. - assert!(combined.contains("--search-query"), "Should show --search-query flag (alias of filter_term)"); - assert!(combined.contains("--user-type"), "Should show --user-type flag"); - assert!(combined.contains("--limit"), "Should show --limit flag"); -} - -/// `x-fern-enum` end-to-end: long `--help` on `users list` (whose -/// `user_type` parameter declares per-value `name`/`description` -/// overrides in the fixture spec) must surface those descriptions -/// next to each value. This is the user-visible side of FER-9864. -#[test] -fn test_users_list_help_shows_per_value_enum_descriptions() { - let output = fixture_cmd() - .args(["users", "list", "--help"]) - .output() - .unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - - for description in [ - "Every user, including external collaborators.", - "Users your enterprise manages.", - "External collaborators only.", - ] { - assert!( - combined.contains(description), - "expected per-value description {description:?} in `users list --help`, got:\n{combined}", - ); - } - - for display in ["All", "Managed", "External"] { - assert!( - combined.contains(display), - "expected display name {display:?} in `users list --help`, got:\n{combined}", - ); - } -} - -#[test] -fn test_help_renders_default_for_both_x_fern_default_and_schema_default() { - // The fixture's `users list` defines two defaults: - // * `user_type` carries `x-fern-default: all` (client-side, sent - // on the wire when the flag is omitted). clap renders this as - // `[default: all]` because we wire it into `Arg::default_value`. - // * `limit` carries the OpenAPI standard `default: 25` (doc-only, - // server applies its own default). We append `[default: 25]` to - // the flag's help text so the user sees the same shape — the - // help surface intentionally does not distinguish where the - // default comes from. - // - // The client/server distinction is enforced by the wire tests, not - // by the rendered help text. Here we only check that both defaults - // are advertised to the user. - let output = fixture_cmd() - .args(["users", "list", "--help"]) - .output() - .unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - combined.contains("[default: all]"), - "x-fern-default value should appear as `[default: all]` in help: {combined}" - ); - assert!( - combined.contains("[default: 25]"), - "schema `default:` should also appear as `[default: 25]` in help: {combined}" - ); -} - - -// --------------------------------------------------------------------------- -// Error / edge-case tests -// --------------------------------------------------------------------------- - -#[test] -fn test_unknown_group_errors() { - let output = fixture_cmd() - .args(["nonexistent-group"]) - .output() - .unwrap(); - assert!(!output.status.success()); -} - -#[test] -fn test_version_flag() { - let output = fixture_cmd().args(["--version"]).output().unwrap(); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("openapi-fixture")); -} - -// --------------------------------------------------------------------------- -// --base-url / OPENAPI_FIXTURE_BASE_URL tests -// --------------------------------------------------------------------------- - -#[test] -fn test_base_url_flag_overrides_spec_url() { - let output = fixture_cmd() - .args(["--base-url", "http://localhost:9999", "users", "getCurrent", "--dry-run"]) - .output() - .unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("http://localhost:9999/"), "expected override URL in output, got: {stdout}"); - assert!(!stdout.contains("http://localhost:9999//"), "double slash must not appear: {stdout}"); - assert!(!stdout.contains("api.fixture.example"), "spec base URL should not appear when overridden"); -} - -#[test] -fn test_base_url_env_var_overrides_spec_url() { - let output = fixture_cmd() - .env("OPENAPI_FIXTURE_BASE_URL", "http://localhost:9998") - .args(["users", "getCurrent", "--dry-run"]) - .output() - .unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("http://localhost:9998"), "expected env var URL in output, got: {stdout}"); - assert!(!stdout.contains("api.fixture.example"), "spec base URL should not appear when env var is set"); -} - -#[test] -fn test_base_url_flag_takes_priority_over_env_var() { - let output = fixture_cmd() - .env("OPENAPI_FIXTURE_BASE_URL", "http://localhost:9998") - .args(["--base-url", "http://localhost:9999", "users", "getCurrent", "--dry-run"]) - .output() - .unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("http://localhost:9999"), "flag should win over env var"); - assert!(!stdout.contains("http://localhost:9998"), "env var URL should not appear when flag is set"); -} - -#[test] -fn test_base_url_flag_rejects_control_characters() { - // Use \x01 (SOH) — a control char that is_control() catches but that the OS - // will pass through as an argument (unlike \x00 which the OS rejects before exec). - let output = fixture_cmd() - .args(["--base-url", "http://localhost:9999\x01evil", "users", "getCurrent", "--dry-run"]) - .output() - .unwrap(); - assert!(!output.status.success(), "control character in --base-url should be rejected"); -} - -#[test] -fn test_base_url_flag_rejects_crlf() { - let output = fixture_cmd() - .args(["--base-url", "http://localhost:9999\r\nevil", "users", "getCurrent", "--dry-run"]) - .output() - .unwrap(); - assert!(!output.status.success(), "CRLF in --base-url should be rejected"); -} - -// --------------------------------------------------------------------------- -// JSON help output tests (--help --format json) -// --------------------------------------------------------------------------- - -#[test] -fn test_json_help_root_returns_operation_list() { - let output = fixture_cmd() - .args(["--help", "--format", "json"]) - .output() - .unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - let root: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON"); - // The root JSON help is a bare array for specs without any - // `x-fern-sdk-variables`, and an object `{ sdkVariables, operations }` - // when at least one variable is declared. Extract the operations list - // from either shape so this test keeps validating the per-operation - // schema regardless of whether the fixture grows variables later. - let ops = root - .as_array() - .cloned() - .or_else(|| { - root.as_object() - .and_then(|obj| obj.get("operations").and_then(|v| v.as_array()).cloned()) - }) - .expect("root JSON help should be an array or { sdkVariables, operations } object"); - assert!(!ops.is_empty(), "should return at least one operation"); - for op in &ops { - assert!(op["operation"].is_string(), "each entry needs 'operation'"); - assert!(op["httpMethod"].is_string(), "each entry needs 'httpMethod'"); - assert!(op["path"].is_string(), "each entry needs 'path'"); - } -} - -#[test] -fn test_json_help_resource_returns_filtered_list() { - let output = fixture_cmd() - .args(["users", "--help", "--format", "json"]) - .output() - .unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - let ops: serde_json::Value = serde_json::from_str(&stdout).expect("output should be valid JSON"); - let arr = ops.as_array().expect("resource JSON help should be an array"); - assert!(!arr.is_empty()); - for op in arr { - let operation = op["operation"].as_str().unwrap(); - assert!( - operation.starts_with("users."), - "all operations should be under 'users': {operation}" - ); - } -} - -#[test] -fn test_json_help_method_returns_schema() { - let output = fixture_cmd() - .args(["users", "get", "--help", "--format", "json"]) - .output() - .unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - let schema: serde_json::Value = - serde_json::from_str(&stdout).expect("output should be valid JSON"); - assert!(schema["operation"].is_string()); - assert!(schema["httpMethod"].is_string()); - assert!(schema["path"].is_string()); - assert_eq!(schema["parameters"]["type"], "object"); - assert!(schema["parameters"]["properties"].is_object()); - assert!(schema["parameters"]["required"].is_array()); -} - -#[test] -fn test_json_help_shows_required_params() { - let output = fixture_cmd() - .args(["users", "get", "--help", "--format", "json"]) - .output() - .unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - let schema: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let required = schema["parameters"]["required"].as_array().unwrap(); - assert!( - required.iter().any(|v| v == "user_id"), - "user_id should be in required: {required:?}" - ); -} - -#[test] -fn test_json_help_shows_param_location() { - let output = fixture_cmd() - .args(["users", "get", "--help", "--format", "json"]) - .output() - .unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - let schema: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let props = &schema["parameters"]["properties"]; - assert_eq!( - props["user_id"]["location"], "path", - "user_id should be a path param" - ); -} - -#[test] -fn test_json_help_short_flag_and_equals_form() { - let output = fixture_cmd() - .args(["users", "-h", "--format=json"]) - .output() - .unwrap(); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - serde_json::from_str::(&stdout) - .expect("-h --format=json should also emit valid JSON"); -} - -#[test] -fn test_json_help_unknown_resource_errors() { - let output = fixture_cmd() - .args(["nonexistent", "--help", "--format", "json"]) - .output() - .unwrap(); - assert!(!output.status.success(), "unknown resource should fail"); -} - -#[test] -fn test_prose_help_still_works_after_change() { - let output = fixture_cmd().args(["--help"]).output().unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - combined.contains("users"), - "prose --help should still list resource groups" - ); - assert!( - !combined.contains("\"httpMethod\""), - "prose --help should not contain JSON fields" - ); -} - -// --------------------------------------------------------------------------- -// x-fern-availability — badges in --help output and non-filtering of -// deprecated operations. Anchored to the `experiments` group in -// `cli/openapi-fixture/openapi.json`. -// --------------------------------------------------------------------------- - -#[test] -fn test_help_shows_beta_badge() { - let output = fixture_cmd().args(["experiments", "--help"]).output().unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - combined.contains("[BETA]"), - "experiments --help should show [BETA] badge:\n{combined}" - ); -} - -#[test] -fn test_help_shows_pre_release_badge() { - let output = fixture_cmd().args(["experiments", "--help"]).output().unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - combined.contains("[PRE-RELEASE]"), - "experiments --help should show [PRE-RELEASE] badge:\n{combined}" - ); -} - -#[test] -fn test_help_shows_deprecated_badge_from_extension() { - let output = fixture_cmd().args(["experiments", "--help"]).output().unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - // The deprecated-op carries `x-fern-availability: deprecated`. - assert!( - combined.contains("[DEPRECATED]"), - "experiments --help should show [DEPRECATED] badge for x-fern-availability:deprecated:\n{combined}" - ); -} - -#[test] -fn test_help_shows_deprecated_badge_from_openapi_deprecated_flag() { - // openapi-deprecated-op uses the standard OpenAPI `deprecated: true` - // flag instead of the extension; it should still surface as - // [DEPRECATED] in help. We check the per-command page to avoid - // collision with the extension-based badge in the prior test. - let output = fixture_cmd() - .args(["experiments", "openapi-deprecated-op", "--help"]) - .output() - .unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - combined.contains("[DEPRECATED]"), - "OpenAPI deprecated:true should also yield [DEPRECATED] badge:\n{combined}" - ); -} - -#[test] -fn test_help_omits_badge_for_generally_available() { - let output = fixture_cmd() - .args(["experiments", "ga-op", "--help"]) - .output() - .unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - !combined.contains("[BETA]") - && !combined.contains("[PRE-RELEASE]") - && !combined.contains("[DEPRECATED]"), - "ga-op help should carry no availability badge:\n{combined}" - ); - // Sanity: the GA summary itself is still rendered. - assert!( - combined.contains("Generally-available"), - "ga-op summary should still render:\n{combined}" - ); -} - -#[test] -fn test_help_shows_parameter_level_badge() { - let output = fixture_cmd() - .args(["experiments", "deprecated-op", "--help"]) - .output() - .unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - // The --legacy-flag parameter is marked beta — its description should - // pick up [BETA] even though the parent operation is [DEPRECATED]. - let legacy_line = combined - .lines() - .find(|l| l.contains("--legacy-flag")) - .unwrap_or_else(|| panic!("--legacy-flag should appear in help:\n{combined}")); - assert!( - legacy_line.contains("[BETA]"), - "--legacy-flag should carry [BETA] badge: {legacy_line}" - ); -} - -#[test] -fn test_json_help_includes_availability_for_operation() { - let output = fixture_cmd() - .args(["experiments", "deprecated-op", "--help", "--format", "json"]) - .output() - .unwrap(); - assert!(output.status.success()); - let v: serde_json::Value = - serde_json::from_slice(&output.stdout).expect("JSON help should parse"); - assert_eq!( - v.get("availability").and_then(|x| x.as_str()), - Some("deprecated"), - "operation JSON help should expose availability field: {v}", - ); - // Parameter-level availability surfaces inside parameters.properties. - let avail = v - .pointer("/parameters/properties/legacy_flag/availability") - .and_then(|x| x.as_str()); - assert_eq!( - avail, - Some("beta"), - "parameter JSON help should expose availability field: {v}", - ); -} - -#[test] -fn test_json_help_omits_availability_for_generally_available() { - let output = fixture_cmd() - .args(["experiments", "ga-op", "--help", "--format", "json"]) - .output() - .unwrap(); - assert!(output.status.success()); - let v: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); - // generally-available IS surfaced as a value in JSON help (useful for - // tooling) — what we do NOT do is render a badge in prose help. - assert_eq!( - v.get("availability").and_then(|x| x.as_str()), - Some("generally-available"), - "ga-op JSON help should still carry availability=generally-available: {v}", - ); -} - -#[test] -fn test_deprecated_operation_remains_callable() { - // Default behavior is badge-only; deprecated ops MUST still be invokable. - // --dry-run exercises the full clap parse + executor path without sending - // an HTTP request. - let output = fixture_cmd() - .args(["experiments", "deprecated-op", "--dry-run"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "deprecated op should still be callable; stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("/experiments/deprecated"), - "dry-run should print the operation path:\n{stdout}" - ); -} - -// --------------------------------------------------------------------------- -// x-fern-sdk-variables — global flag surface -// -// These tests exercise the spec-level `x-fern-sdk-variables` extension and the -// per-parameter `x-fern-sdk-variable` reference. The openapi-fixture spec -// declares: -// -// x-fern-sdk-variables: -// gardenId: -// type: string -// description: The garden tenant identifier used to scope all zone operations. -// -// /gardens/{gardenId}/zones: -// get: -// parameters: -// - name: gardenId -// in: path -// required: true -// x-fern-sdk-variable: gardenId -// -// The CLI must (a) expose `--garden-id` as a global root flag with `$GARDEN_ID` -// fallback, (b) hide the variable-bound path param from per-operation flags, -// and (c) error with a message naming both forms if neither is set when an -// operation that needs the variable runs. -// --------------------------------------------------------------------------- - -#[test] -fn test_sdk_variable_appears_in_root_help_with_description_and_env() { - let output = fixture_cmd().arg("--help").output().unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - assert!( - combined.contains("--garden-id"), - "global --garden-id flag should appear in root --help: {combined}" - ); - assert!( - combined.contains("GARDEN_ID"), - "env-var fallback GARDEN_ID should appear in root --help: {combined}" - ); - assert!( - combined.contains("garden tenant identifier"), - "variable description should surface in root --help: {combined}" - ); -} - -#[test] -fn test_sdk_variable_is_not_per_operation_positional_in_help() { - // The `gardenId` path parameter is variable-bound and must not surface as - // a per-operation `--garden-id` flag on `zones list`. (The same flag still - // appears via the global propagation, but it must NOT be re-registered at - // the leaf — that would be a per-op duplicate.) - let output = fixture_cmd() - .args(["zones", "list", "--help"]) - .output() - .unwrap(); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - // The Usage line must NOT prompt for a per-op `--garden-id `. - let usage_line = combined - .lines() - .find(|l| l.trim_start().starts_with("Usage:")) - .unwrap_or(""); - assert!( - !usage_line.contains("--garden-id "), - "variable-bound param should not appear as a per-op positional in Usage: {usage_line}" - ); -} - -#[test] -fn test_sdk_variable_missing_emits_validation_error_naming_both_forms() { - // No --garden-id flag, no $GARDEN_ID env var. --dry-run forces the - // executor path so we get a validation error rather than a real HTTP - // call. The error message must mention both the CLI flag and the env - // var so users know either form is acceptable. - let output = fixture_cmd() - .env_remove("GARDEN_ID") - .args(["zones", "list", "--dry-run"]) - .output() - .unwrap(); - assert!( - !output.status.success(), - "missing variable should fail: stdout: {} stderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - assert!( - combined.contains("--garden-id"), - "error should name the --garden-id CLI flag: {combined}" - ); - assert!( - combined.contains("GARDEN_ID"), - "error should name the GARDEN_ID env var: {combined}" - ); -} - -#[test] -fn test_sdk_variable_cli_flag_substitutes_path() { - // --garden-id at the root flows through the global flag layer and is - // substituted into the path template at request time. --dry-run prints - // the URL the CLI would call. - let output = fixture_cmd() - .env_remove("GARDEN_ID") - .args(["--garden-id", "my-garden", "zones", "list", "--dry-run"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("/gardens/my-garden/zones"), - "variable should be substituted into URL path: {stdout}" - ); - assert!( - !stdout.contains("{gardenId}"), - "literal placeholder should not remain in URL: {stdout}" - ); -} - -#[test] -fn test_sdk_variable_env_var_substitutes_path_when_flag_absent() { - // No --garden-id flag, but $GARDEN_ID is set. clap's .env() binding - // surfaces the env value as the flag's resolved value, which then - // substitutes into the path template. - let output = fixture_cmd() - .env("GARDEN_ID", "fromenv") - .args(["zones", "list", "--dry-run"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("/gardens/fromenv/zones"), - "env var should substitute when flag is absent: {stdout}" - ); -} - -#[test] -fn test_sdk_variable_cli_flag_wins_over_env_var() { - // Both --garden-id and $GARDEN_ID are set. Resolution order says the - // CLI flag wins. - let output = fixture_cmd() - .env("GARDEN_ID", "fromenv") - .args(["--garden-id", "fromflag", "zones", "list", "--dry-run"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("/gardens/fromflag/zones"), - "CLI flag value should win over env var: {stdout}" - ); - assert!( - !stdout.contains("fromenv"), - "env var value should not leak when flag is present: {stdout}" - ); -} - -#[test] -fn test_sdk_variable_params_json_can_supply_variable_bound_value() { - // `--params` is documented as "overrides individual flags" and must - // also be allowed to supply variable-bound path parameters when the - // global flag and env var are both unset — otherwise it's a regression - // of the contract for plain path params. - let output = fixture_cmd() - .env_remove("GARDEN_ID") - .args([ - "zones", - "list", - "--params", - r#"{"gardenId":"fromparams"}"#, - "--dry-run", - ]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("/gardens/fromparams/zones"), - "--params value should substitute into URL: {stdout}" - ); -} - -#[test] -fn test_sdk_variable_params_json_value_wins_over_global_flag() { - // Resolution within the params map: `--params` overrides individual - // flags. Variable-bound globals fill in first, then `--params` JSON - // applies on top, so a `--params` value for the same key wins. - let output = fixture_cmd() - .env_remove("GARDEN_ID") - .args([ - "--garden-id", - "fromflag", - "zones", - "list", - "--params", - r#"{"gardenId":"fromparams"}"#, - "--dry-run", - ]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("/gardens/fromparams/zones"), - "--params value should win over global flag for the same key: {stdout}" - ); -} - -#[test] -fn test_sdk_variable_json_help_annotates_variable_bound_param_for_agents() { - // The JSON help (`--help --format json`) is the machine-readable - // contract LLM agents introspect. For a variable-bound path - // parameter, the per-op schema must: - // 1. NOT include the param in `required` (no per-op flag exists) - // 2. Annotate it with `binding: "sdk-variable"` plus the resolved - // global flag and env var so the agent knows how to supply it. - let output = fixture_cmd() - .args(["zones", "list", "--help", "--format", "json"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let schema: serde_json::Value = serde_json::from_str(&stdout) - .unwrap_or_else(|e| panic!("expected valid JSON: {e}; got: {stdout}")); - - let required = schema["parameters"]["required"].as_array().unwrap(); - assert!( - !required.iter().any(|v| v == "gardenId"), - "variable-bound param must not appear in `required`, got: {required:?}" - ); - let garden = &schema["parameters"]["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_sdk_variable_root_json_help_exposes_sdk_variables_section() { - // At the spec root, the JSON help must list the declared SDK - // variables so an agent can discover the root-level globals without - // scanning every operation. Wrapped format only kicks in when at - // least one variable is declared. - let output = fixture_cmd() - .args(["--help", "--format", "json"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let root: serde_json::Value = serde_json::from_str(&stdout) - .unwrap_or_else(|e| panic!("expected valid JSON: {e}; got: {stdout}")); - - let vars = root["sdkVariables"] - .as_array() - .expect("root JSON help should expose sdkVariables when declared"); - let garden = vars - .iter() - .find(|v| v["name"] == "gardenId") - .expect("gardenId variable should be listed in sdkVariables"); - assert_eq!(garden["type"], "string"); - assert_eq!(garden["globalFlag"], "--garden-id"); - assert_eq!(garden["envVar"], "GARDEN_ID"); - assert!( - root["operations"].is_array(), - "operations array should still be present alongside sdkVariables" - ); -} - -// --------------------------------------------------------------------------- -// Shell completion tests -// --------------------------------------------------------------------------- - -#[test] -fn test_completion_bash_exits_zero_and_contains_known_subcommand() { - let output = fixture_cmd() - .args(["completion", "bash"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "`openapi-fixture completion bash` failed:\n{}", - String::from_utf8_lossy(&output.stderr), - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("_openapi-fixture"), - "bash completion should reference the binary name" - ); - assert!( - stdout.contains("files"), - "bash completion should include 'files' subcommand from fixture spec" - ); -} - -#[test] -fn test_completion_zsh_exits_zero_and_contains_known_subcommand() { - let output = fixture_cmd() - .args(["completion", "zsh"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "`openapi-fixture completion zsh` failed:\n{}", - String::from_utf8_lossy(&output.stderr), - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("#compdef openapi-fixture"), - "zsh completion should contain compdef header" - ); - assert!( - stdout.contains("files"), - "zsh completion should include 'files' subcommand from fixture spec" - ); -} - -#[test] -fn test_completion_fish_exits_zero() { - let output = fixture_cmd() - .args(["completion", "fish"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "`openapi-fixture completion fish` failed:\n{}", - String::from_utf8_lossy(&output.stderr), - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("complete"), - "fish completion should contain fish completion directives" - ); -} - -#[test] -fn test_completion_powershell_exits_zero() { - let output = fixture_cmd() - .args(["completion", "powershell"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "`openapi-fixture completion powershell` failed:\n{}", - String::from_utf8_lossy(&output.stderr), - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - !stdout.is_empty(), - "powershell completion output should not be empty" - ); -} - -#[test] -fn test_completion_invalid_shell_exits_nonzero() { - let output = fixture_cmd() - .args(["completion", "invalid"]) - .output() - .unwrap(); - assert!( - !output.status.success(), - "`openapi-fixture completion invalid` should exit non-zero" - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("invalid shell") && stderr.contains("Expected one of"), - "`openapi-fixture completion invalid` should print a diagnostic on stderr, got:\n{stderr}" - ); -} - -#[test] -fn test_completion_no_shell_shows_help_exits_zero() { - let output = fixture_cmd() - .args(["completion"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "`openapi-fixture completion` (no shell) should exit 0" - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("completion") || stdout.contains("Usage"), - "`openapi-fixture completion` should show help on stdout, got:\n{stdout}" - ); -} - -#[test] -fn test_completion_appears_in_help() { - let output = fixture_cmd().arg("--help").output().unwrap(); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("completion"), - "`--help` should list the completion subcommand" - ); -} - -#[test] -fn test_completion_bash_includes_global_flags() { - let output = fixture_cmd() - .args(["completion", "bash"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "completion bash failed:\n{}", - String::from_utf8_lossy(&output.stderr), - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("garden-id"), - "bash completion should include --garden-id from x-fern-sdk-variables" - ); - assert!( - stdout.contains("api-stage"), - "bash completion should include --api-stage from x-fern-global-headers" - ); - assert!( - stdout.contains("base-url"), - "bash completion should include --base-url built-in flag" - ); -} - -#[test] -fn test_completion_zsh_includes_global_flags() { - let output = fixture_cmd() - .args(["completion", "zsh"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "completion zsh failed:\n{}", - String::from_utf8_lossy(&output.stderr), - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("garden-id"), - "zsh completion should include --garden-id from x-fern-sdk-variables" - ); - assert!( - stdout.contains("api-stage"), - "zsh completion should include --api-stage from x-fern-global-headers" - ); -} - -#[test] -fn test_completion_fish_includes_global_flags() { - let output = fixture_cmd() - .args(["completion", "fish"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "completion fish failed:\n{}", - String::from_utf8_lossy(&output.stderr), - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("garden-id"), - "fish completion should include --garden-id from x-fern-sdk-variables" - ); - assert!( - stdout.contains("api-stage"), - "fish completion should include --api-stage from x-fern-global-headers" - ); -} - -#[test] -fn test_completion_with_boolean_flag_prefix() { - // --dry-run is a boolean flag and must NOT swallow "completion" as - // its value. Regression test for the wants_completion heuristic. - let output = fixture_cmd() - .args(["--dry-run", "completion", "bash"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "`openapi-fixture --dry-run completion bash` should succeed:\n{}", - String::from_utf8_lossy(&output.stderr), - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("_openapi-fixture"), - "bash completion with --dry-run prefix should still produce a completion script" - ); -} - -// --------------------------------------------------------------------------- -// Man page tests -// --------------------------------------------------------------------------- - -#[test] -fn test_man_outputs_roff_exits_zero() { - let output = fixture_cmd().args(["man"]).output().unwrap(); - assert!( - output.status.success(), - "`openapi-fixture man` failed:\n{}", - String::from_utf8_lossy(&output.stderr), - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains(".TH"), - "man page should contain a .TH title-header macro" - ); - assert!( - stdout.contains("OPENAPI-FIXTURE") || stdout.contains("openapi-fixture"), - "man page should contain the binary name (upper or lower case)" - ); - assert!( - stdout.contains(".SH NAME"), - "man page should contain a .SH NAME section" - ); -} - -#[test] -fn test_man_help_shows_install_snippet() { - let output = fixture_cmd().args(["man", "--help"]).output().unwrap(); - assert!( - output.status.success(), - "`openapi-fixture man --help` failed:\n{}", - String::from_utf8_lossy(&output.stderr), - ); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - assert!( - combined.contains("EXAMPLES"), - "man --help should show EXAMPLES section" - ); - assert!( - combined.contains("groff"), - "man --help should mention groff in the install snippet" - ); -} - -#[test] -fn test_man_with_dry_run_flag_does_not_dispatch() { - // --dry-run is a boolean flag and must NOT swallow "man" as its - // value. Regression test mirroring the completion boolean-flag test. - let output = fixture_cmd() - .args(["--dry-run", "man"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "`openapi-fixture --dry-run man` should succeed:\n{}", - String::from_utf8_lossy(&output.stderr), - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains(".TH"), - "man page with --dry-run prefix should still produce roff output" - ); -} - -#[test] -fn test_man_appears_in_help() { - let output = fixture_cmd().arg("--help").output().unwrap(); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("man"), - "`--help` should list the man subcommand" - ); -} diff --git a/generators/cli/sdk/tests/extension_surface_behavior.rs b/generators/cli/sdk/tests/extension_surface_behavior.rs new file mode 100644 index 000000000000..bcaea1188e5a --- /dev/null +++ b/generators/cli/sdk/tests/extension_surface_behavior.rs @@ -0,0 +1,840 @@ +//! Behavior tests for the extension surface (`app::CliApp` + `Binding`). +//! +//! Every test builds a `CliApp` with a mock binding, registers hooks or +//! Tier 1 methods, invokes the CLI via `try_run_from_with_output(argv, &mut buf)`, +//! and asserts on **observable output** (captured buffer, exit code). Tests that +//! only check builder state do not belong here. +//! +//! See +//! for why the single-binding fast path was removed and these behavior +//! tests are required. + +use std::sync::{Arc, Mutex}; + +use serde_json::{json, Value}; + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::binding::{Binding, BoxFuture, DispatchResult}; +use fern_cli_sdk::error::CliError; + +// ── Mock Binding ──────────────────────────────────────────────────── + +/// A minimal `Binding` impl that returns a controlled response. +/// Used to test CliApp-scope hooks and Tier 1 methods in isolation +/// without needing a real spec or wiremock server. +struct MockBinding { + response: Value, + error: Option, + dispatched: Arc>>>, +} + +impl MockBinding { + fn new(response: Value) -> Self { + Self { + response, + error: None, + dispatched: Arc::new(Mutex::new(Vec::new())), + } + } + + fn with_error(error: CliError) -> Self { + Self { + response: Value::Null, + error: Some(error), + dispatched: Arc::new(Mutex::new(Vec::new())), + } + } + + fn dispatched_log(&self) -> Arc>>> { + Arc::clone(&self.dispatched) + } +} + +impl Binding for MockBinding { + fn name(&self) -> &str { + "mock" + } + + fn set_cli_name(&mut self, _name: &str) {} + + fn build_command(&self) -> Result { + Ok(clap::Command::new("mock") + .subcommand( + clap::Command::new("users") + .subcommand(clap::Command::new("get")) + .subcommand(clap::Command::new("list")) + .subcommand(clap::Command::new("create")), + ) + .subcommand( + clap::Command::new("files") + .subcommand(clap::Command::new("get")) + .subcommand(clap::Command::new("upload")), + )) + } + + fn dispatch<'a>( + &'a self, + _root_matches: &'a clap::ArgMatches, + _sub_matches: &'a clap::ArgMatches, + op_path: &'a [String], + ) -> BoxFuture<'a, Result> { + let path = op_path.to_vec(); + let log = Arc::clone(&self.dispatched); + let response = self.response.clone(); + let error = self.error.as_ref().map(|e| e.duplicate()); + + Box::pin(async move { + log.lock().unwrap().push(path); + if let Some(err) = error { + return Err(err); + } + Ok(DispatchResult::Value(response)) + }) + } +} + +/// Run a `CliApp` via `try_run_from_with_output` and return (captured stdout, exit code). +fn run_app(app: CliApp, args: I) -> (String, i32) +where + I: IntoIterator, + T: Into, +{ + let mut buf = Vec::new(); + let code = app.try_run_from_with_output(args, &mut buf); + let output = String::from_utf8(buf).expect("output should be valid UTF-8"); + (output, code) +} + +// ── Tier 2: transform_response ────────────────────────────────────── + +#[test] +fn transform_response_strips_data_wrapper() { + let mock = MockBinding::new(json!({ + "data": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}], + "meta": {"total": 2} + })); + + let app = CliApp::new("test-cli") + .binding(mock) + .transform_response(&["**"], |value, _path| async move { + if let Some(data) = value.get("data").cloned() { + Ok(data) + } else { + Ok(value) + } + }); + + let (stdout, exit_code) = run_app(app, ["test-cli", "users", "list"]); + + assert_eq!(exit_code, 0, "expected success, got exit code {exit_code}"); + let parsed: Value = serde_json::from_str(stdout.trim()).expect("stdout should be valid JSON"); + assert!(parsed.is_array(), "expected array, got: {parsed}"); + assert_eq!(parsed.as_array().unwrap().len(), 2); + assert_eq!(parsed[0]["name"], "Alice"); +} + +#[test] +fn transform_response_only_fires_for_matching_path() { + let mock = MockBinding::new(json!({"original": true})); + + let app = CliApp::new("test-cli") + .binding(mock) + .transform_response(&["files", "**"], |_value, _path| async move { + Ok(json!({"transformed": true})) + }); + + let (stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); + + assert_eq!(exit_code, 0); + let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(parsed["original"], true, "hook should not fire for users/get"); +} + +#[test] +fn transform_response_chain_applies_in_order() { + let mock = MockBinding::new(json!({"count": 1})); + + let app = CliApp::new("test-cli") + .binding(mock) + .transform_response(&["**"], |mut value, _path| async move { + if let Some(n) = value.get("count").and_then(|v| v.as_i64()) { + value["count"] = json!(n * 2); + } + Ok(value) + }) + .transform_response(&["**"], |mut value, _path| async move { + if let Some(n) = value.get("count").and_then(|v| v.as_i64()) { + value["count"] = json!(n + 10); + } + Ok(value) + }); + + let (stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); + + assert_eq!(exit_code, 0); + let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); + // 1 * 2 = 2, then 2 + 10 = 12 + assert_eq!(parsed["count"], 12, "hooks should chain: 1 → 2 → 12"); +} + +// ── Tier 2: recover_error ─────────────────────────────────────────── + +#[test] +fn recover_error_converts_404_to_success() { + let mock = MockBinding::with_error(CliError::Api { + code: 404, + message: "Not Found".to_string(), + reason: "not_found".to_string(), + }); + + let app = CliApp::new("test-cli") + .binding(mock) + .recover_error(&["**"], |_err, _path| async move { + Ok(Some(json!({"deleted": true, "status": "already_gone"}))) + }); + + let (stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); + + assert_eq!(exit_code, 0, "recover_error should produce exit code 0"); + let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(parsed["deleted"], true); + assert_eq!(parsed["status"], "already_gone"); +} + +#[test] +fn recover_error_none_lets_error_propagate() { + let mock = MockBinding::with_error(CliError::Api { + code: 500, + message: "Internal Server Error".to_string(), + reason: "server_error".to_string(), + }); + + let app = CliApp::new("test-cli") + .binding(mock) + .recover_error(&["**"], |_err, _path| async move { + Ok(None) + }); + + let (_stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); + + assert_ne!(exit_code, 0, "error should propagate when hook returns None"); +} + +#[test] +fn recover_error_only_fires_for_matching_path() { + let mock = MockBinding::with_error(CliError::Api { + code: 404, + message: "Not Found".to_string(), + reason: "not_found".to_string(), + }); + + let app = CliApp::new("test-cli") + .binding(mock) + .recover_error(&["files", "**"], |_err, _path| async move { + Ok(Some(json!({"recovered": true}))) + }); + + let (_stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); + + assert_ne!(exit_code, 0, "hook should not fire for users/get path"); +} + +// ── Tier 1: alias ─────────────────────────────────────────────────── + +#[test] +fn alias_produces_same_output_as_canonical_name() { + let response = json!({"id": 42, "name": "test-user"}); + + // Run with alias. + let mock1 = MockBinding::new(response.clone()); + let app1 = CliApp::new("test-cli") + .binding(mock1) + .alias(&["users"], "u"); + + let (stdout_alias, code_alias) = run_app(app1, ["test-cli", "u", "get"]); + + // Run with canonical name. + let mock2 = MockBinding::new(response); + let app2 = CliApp::new("test-cli") + .binding(mock2) + .alias(&["users"], "u"); + + let (stdout_canonical, code_canonical) = run_app(app2, ["test-cli", "users", "get"]); + + assert_eq!(code_alias, 0, "alias should work: exit code {code_alias}"); + assert_eq!(code_canonical, 0); + assert_eq!( + stdout_alias.trim(), + stdout_canonical.trim(), + "alias output should match canonical output" + ); + let parsed: Value = serde_json::from_str(stdout_alias.trim()).unwrap(); + assert_eq!(parsed["name"], "test-user"); +} + +// ── Tier 1: hide ──────────────────────────────────────────────────── + +#[test] +fn hide_command_still_executes() { + let mock = MockBinding::new(json!({"hidden_result": true})); + + let app = CliApp::new("test-cli") + .binding(mock) + .hide(&["files"]); + + let (stdout, exit_code) = run_app(app, ["test-cli", "files", "get"]); + assert_eq!(exit_code, 0, "hidden command should still execute"); + let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(parsed["hidden_result"], true); +} + +// ── Tier 1: stability ─────────────────────────────────────────────── + +#[test] +fn stability_badge_appears_in_help() { + use fern_cli_sdk::stability::Stability; + + let mock = MockBinding::new(json!({})); + let app = CliApp::new("test-cli") + .binding(mock) + .stability(&["users"], Stability::Beta); + + let (help_stdout, exit_code) = run_app(app, ["test-cli", "--help"]); + + assert_eq!(exit_code, 0, "help should produce exit code 0"); + assert!( + help_stdout.contains("[beta]"), + "beta badge should appear in help: {help_stdout}" + ); +} + +// ── Tier 1: deprecate ─────────────────────────────────────────────── + +#[test] +fn deprecate_marks_command_in_help() { + let mock = MockBinding::new(json!({})); + let app = CliApp::new("test-cli") + .binding(mock) + .deprecate(&["files"], "Use 'documents' instead"); + + let (help_stdout, exit_code) = run_app(app, ["test-cli", "--help"]); + + assert_eq!(exit_code, 0); + assert!( + help_stdout.contains("[deprecated]"), + "deprecated badge should appear: {help_stdout}" + ); +} + +// ── No single-binding fast path ───────────────────────────────────── + +#[test] +fn single_binding_still_runs_full_pipeline() { + // This is the critical test: with only one binding, hooks MUST fire. + // PR #62's bug was that single-binding CLIs bypassed hooks. + let mock = MockBinding::new(json!({"raw": "untouched"})); + let log = mock.dispatched_log(); + + let app = CliApp::new("test-cli") + .binding(mock) + .transform_response(&["**"], |_value, _path| async move { + Ok(json!({"hook_fired": true})) + }); + + let (stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); + + assert_eq!(exit_code, 0); + let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!( + parsed["hook_fired"], true, + "transform_response MUST fire even with a single binding" + ); + let dispatches = log.lock().unwrap(); + assert_eq!(dispatches.len(), 1); + assert_eq!(dispatches[0], vec!["users", "get"]); +} + +#[test] +fn single_binding_recover_error_fires() { + let mock = MockBinding::with_error(CliError::Api { + code: 503, + message: "Service Unavailable".to_string(), + reason: "unavailable".to_string(), + }); + + let app = CliApp::new("test-cli") + .binding(mock) + .recover_error(&["**"], |_err, _path| async move { + Ok(Some(json!({"fallback": true}))) + }); + + let (stdout, exit_code) = run_app(app, ["test-cli", "users", "list"]); + + assert_eq!(exit_code, 0, "recover_error must fire for single binding"); + let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(parsed["fallback"], true); +} + +// ── Dispatch recording ────────────────────────────────────────────── + +#[test] +fn dispatch_records_correct_op_path() { + let mock = MockBinding::new(json!({})); + let log = mock.dispatched_log(); + + let app = CliApp::new("test-cli").binding(mock); + + let (_stdout, exit_code) = run_app(app, ["test-cli", "users", "create"]); + + assert_eq!(exit_code, 0); + let dispatches = log.lock().unwrap(); + assert_eq!(dispatches.len(), 1); + assert_eq!(dispatches[0], vec!["users", "create"]); +} + +// ── Multi-binding dispatch ─────────────────────────────────────────── + +/// A second binding providing a distinct subtree (`orders`). +struct OrdersBinding { + response: Value, +} + +impl OrdersBinding { + fn new(response: Value) -> Self { + Self { response } + } +} + +impl Binding for OrdersBinding { + fn name(&self) -> &str { + "orders" + } + + fn set_cli_name(&mut self, _name: &str) {} + + fn build_command(&self) -> Result { + Ok(clap::Command::new("orders") + .subcommand( + clap::Command::new("orders") + .subcommand(clap::Command::new("get")) + .subcommand(clap::Command::new("list")), + )) + } + + fn dispatch<'a>( + &'a self, + _root_matches: &'a clap::ArgMatches, + _sub_matches: &'a clap::ArgMatches, + _op_path: &'a [String], + ) -> BoxFuture<'a, Result> { + let response = self.response.clone(); + Box::pin(async move { Ok(DispatchResult::Value(response)) }) + } +} + +#[test] +fn multi_binding_routes_to_correct_binding() { + let mock_users = MockBinding::new(json!({"source": "users-binding"})); + let mock_orders = OrdersBinding::new(json!({"source": "orders-binding"})); + let user_log = mock_users.dispatched_log(); + + let app = CliApp::new("test-cli") + .binding(mock_users) + .binding(mock_orders); + + // Route to users binding. + let (stdout, code) = run_app(app, ["test-cli", "users", "get"]); + assert_eq!(code, 0); + let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(parsed["source"], "users-binding"); + let dispatches = user_log.lock().unwrap(); + assert_eq!(dispatches.len(), 1); +} + +#[test] +fn multi_binding_hooks_fire_for_both_bindings() { + let mock_users = MockBinding::new(json!({"raw": true})); + let mock_orders = OrdersBinding::new(json!({"raw": true})); + + let app = CliApp::new("test-cli") + .binding(mock_users) + .binding(mock_orders) + .transform_response(&["**"], |_v, _p| async move { + Ok(json!({"hooked": true})) + }); + + let (stdout, code) = run_app(app, ["test-cli", "users", "get"]); + assert_eq!(code, 0); + let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!(parsed["hooked"], true, "hook should fire for users binding"); +} + +// ── recover_error: decline preserves original error ───────────────── + +#[test] +fn recover_error_decline_preserves_original_for_next_hook() { + let mock = MockBinding::with_error(CliError::Api { + code: 404, + message: "Not Found".to_string(), + reason: "not_found".to_string(), + }); + + let app = CliApp::new("test-cli") + .binding(mock) + // First hook declines. + .recover_error(&["**"], |_err, _path| async move { Ok(None) }) + // Second hook recovers — should see the original 404 error, + // not a synthetic fallback. + .recover_error(&["**"], |err, _path| async move { + let msg = format!("{err}"); + if msg.contains("Not Found") { + Ok(Some(json!({"recovered_from_original": true}))) + } else { + Ok(None) + } + }); + + let (stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); + + assert_eq!(exit_code, 0, "second hook should recover"); + let parsed: Value = serde_json::from_str(stdout.trim()).unwrap(); + assert_eq!( + parsed["recovered_from_original"], true, + "second hook should see the original error, not a fallback" + ); +} + +// ── Tier 1 ops preserve parent settings ───────────────────────────── + +#[test] +fn tier1_ops_preserve_arg_required_else_help() { + // Verify that applying a Tier 1 op (alias) via mut_subcommand + // doesn't lose the arg_required_else_help setting on the root. + let mock = MockBinding::new(json!({"ok": true})); + + let app = CliApp::new("test-cli") + .binding(mock) + .alias(&["users"], "u"); + + // Invoke without a subcommand — should show help (exit 0) rather + // than silently succeeding or crashing. + let (stdout, exit_code) = run_app(app, ["test-cli"]); + assert_eq!(exit_code, 0, "should show help for missing subcommand"); + assert!( + stdout.contains("Usage") || stdout.contains("usage"), + "output should contain usage text: {stdout}" + ); +} + +// ── Multi-binding context merging ──────────────────────────────────── + +/// A mergeable context type for testing `merge_binding_context`. +struct MergeableContext { + entries: Vec, +} + +/// A binding whose `merge_binding_context` merges entries from multiple +/// bindings into a single `MergeableContext`, mirroring how +/// `OpenApiBinding` merges `AppContext` entries. +struct MergeableMockBinding { + name: &'static str, + entry: String, + commands: clap::Command, +} + +impl MergeableMockBinding { + fn new(name: &'static str, entry: &str, commands: clap::Command) -> Self { + Self { + name, + entry: entry.to_string(), + commands, + } + } +} + +impl Binding for MergeableMockBinding { + fn name(&self) -> &str { + self.name + } + fn set_cli_name(&mut self, _name: &str) {} + + fn build_command(&self) -> Result { + Ok(self.commands.clone()) + } + + fn dispatch<'a>( + &'a self, + _root_matches: &'a clap::ArgMatches, + _sub_matches: &'a clap::ArgMatches, + _op_path: &'a [String], + ) -> BoxFuture<'a, Result> { + Box::pin(async { Ok(DispatchResult::Value(json!({}))) }) + } + + fn merge_binding_context( + &self, + _matches: &clap::ArgMatches, + existing: Option>, + ) -> Result>, CliError> { + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.entries.push(self.entry.clone()); + Ok(Some(ctx as Box)) + } + Err(_) => Ok(Some(Box::new(MergeableContext { + entries: vec![self.entry.clone()], + }))), + }, + None => Ok(Some(Box::new(MergeableContext { + entries: vec![self.entry.clone()], + }))), + } + } +} + +#[test] +fn multi_binding_custom_command_receives_merged_context() { + let binding_a = MergeableMockBinding::new( + "alpha", + "alpha-entry", + clap::Command::new("alpha") + .subcommand(clap::Command::new("users").subcommand(clap::Command::new("get"))), + ); + let binding_b = MergeableMockBinding::new( + "beta", + "beta-entry", + clap::Command::new("beta") + .subcommand(clap::Command::new("orders").subcommand(clap::Command::new("list"))), + ); + + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let captured_clone = Arc::clone(&captured); + + let app = CliApp::new("test-cli") + .binding(binding_a) + .binding(binding_b) + .command( + clap::Command::new("status"), + Box::new(move |_matches, ctx| { + let merged = ctx.downcast_ref::().ok_or_else(|| { + CliError::Validation("expected MergeableContext".into()) + })?; + *captured_clone.lock().unwrap() = merged.entries.clone(); + Ok(()) + }), + ); + + let (_stdout, exit_code) = run_app(app, ["test-cli", "status"]); + assert_eq!(exit_code, 0, "custom command should succeed"); + let entries = captured.lock().unwrap(); + assert_eq!(entries.len(), 2, "should have entries from both bindings: {entries:?}"); + assert!(entries.contains(&"alpha-entry".to_string()), "missing alpha: {entries:?}"); + assert!(entries.contains(&"beta-entry".to_string()), "missing beta: {entries:?}"); +} + +// ── Hook pattern validation ───────────────────────────────────────── + +#[test] +fn hook_pattern_matching_no_operations_hard_fails() { + let mock = MockBinding::new(json!({"ok": true})); + + // "user" is a typo — the real command is "users" + let app = CliApp::new("test-cli") + .binding(mock) + .transform_response(&["user", "get"], |v, _p| async move { Ok(v) }); + + let (_stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); + assert_ne!(exit_code, 0, "typo in hook pattern should hard fail"); +} + +#[test] +fn hook_pattern_matching_valid_operations_succeeds() { + let mock = MockBinding::new(json!({"ok": true})); + + let app = CliApp::new("test-cli") + .binding(mock) + .transform_response(&["users", "get"], |v, _p| async move { Ok(v) }); + + let (_stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); + assert_eq!(exit_code, 0, "valid hook pattern should pass validation"); +} + +#[test] +fn hook_pattern_globstar_matches_existing_operations() { + let mock = MockBinding::new(json!({"ok": true})); + + let app = CliApp::new("test-cli") + .binding(mock) + .transform_response(&["users", "**"], |v, _p| async move { Ok(v) }); + + let (_stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); + assert_eq!(exit_code, 0, "globstar matching real operations should pass"); +} + +#[test] +fn recover_error_pattern_matching_no_operations_hard_fails() { + let mock = MockBinding::new(json!({"ok": true})); + + let app = CliApp::new("test-cli") + .binding(mock) + .recover_error(&["nonexistent", "path"], |e, _p| async move { Err(e) }); + + let (_stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); + assert_ne!(exit_code, 0, "typo in recover_error pattern should hard fail"); +} + +// ── No bindings → error ───────────────────────────────────────────── + +#[test] +fn no_bindings_returns_error() { + let app = CliApp::new("test-cli"); + + let (_stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); + + assert_ne!(exit_code, 0, "no bindings should produce an error"); +} + +// ── --format validation ───────────────────────────────────────────── + +#[test] +fn unknown_format_returns_validation_error() { + let app = CliApp::new("test-cli") + .binding(MockBinding::new(json!({"ok": true}))); + + let (stdout, exit_code) = run_app(app, ["test-cli", "--format", "xml", "users", "get"]); + + assert_eq!(exit_code, 3, "unknown --format should exit with validation code: {stdout}"); + assert!( + stdout.contains("unknown output format"), + "error should mention the unknown format, got: {stdout}", + ); +} + +// ── Root Auth ─────────────────────────────────────────────────────── + +#[test] +fn root_auth_propagates_to_binding() { + use fern_cli_sdk::auth::BearerAuth; + + let app = CliApp::new("test-cli") + .auth(BearerAuth::new("bearerAuth").env("MY_TOKEN")) + .binding(MockBinding::new(json!({"ok": true}))); + + let (stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); + + assert_eq!(exit_code, 0, "root auth + mock binding should succeed: {stdout}"); +} + +#[test] +fn root_auth_multiple_schemes() { + use fern_cli_sdk::auth::{ApiKeyAuth, BearerAuth}; + + let app = CliApp::new("test-cli") + .auth(BearerAuth::new("bearerAuth").env("TOKEN")) + .auth(ApiKeyAuth::new("apiKey").env("KEY")) + .binding(MockBinding::new(json!({"ok": true}))); + + let (stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); + + assert_eq!(exit_code, 0, "multiple root auth schemes should work: {stdout}"); +} + +#[test] +fn root_auth_basic_scheme() { + use fern_cli_sdk::auth::BasicAuth; + + let app = CliApp::new("test-cli") + .auth(BasicAuth::new("httpBasic").username_env("USER").password_env("PASS")) + .binding(MockBinding::new(json!({"ok": true}))); + + let (stdout, exit_code) = run_app(app, ["test-cli", "users", "get"]); + + assert_eq!(exit_code, 0, "root basic auth should work: {stdout}"); +} + +#[test] +fn root_auth_validation_warns_on_partial_binding() { + use fern_cli_sdk::auth::BearerAuth; + use fern_cli_sdk::openapi::OpenApiBinding; + + // Minimal spec that declares `bearerAuth` in securitySchemes + let spec = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +paths: + /users: + get: + x-fern-sdk-group-name: users + x-fern-sdk-method-name: list + security: + - bearerAuth: [] + responses: + "200": + description: ok +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + apiKeyAuth: + type: apiKey + in: header + name: X-API-Key +"#; + + // Register only bearerAuth — apiKeyAuth is missing → should warn but + // still succeed (multi-spec binaries may intentionally bind only a + // subset of schemes). + let app = CliApp::new("test-cli") + .auth(BearerAuth::new("bearerAuth").env("TOKEN")) + .binding(OpenApiBinding::new().spec(spec)); + + let (_stdout, exit_code) = run_app(app, ["test-cli", "--help"]); + + assert_eq!(exit_code, 0, "partial auth binding should succeed (unbound schemes get a warning)"); +} + +#[test] +fn root_auth_validation_passes_when_all_schemes_registered() { + use fern_cli_sdk::auth::{ApiKeyAuth, BearerAuth}; + use fern_cli_sdk::openapi::OpenApiBinding; + + let spec = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +paths: + /users: + get: + x-fern-sdk-group-name: users + x-fern-sdk-method-name: list + security: + - bearerAuth: [] + responses: + "200": + description: ok +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + apiKeyAuth: + type: apiKey + in: header + name: X-API-Key +"#; + + // Register both schemes → validation passes + let app = CliApp::new("test-cli") + .auth(BearerAuth::new("bearerAuth").env("TOKEN")) + .auth(ApiKeyAuth::new("apiKeyAuth").env("KEY")) + .binding(OpenApiBinding::new().spec(spec)); + + let (stdout, exit_code) = run_app(app, ["test-cli", "--help"]); + + assert_eq!(exit_code, 0, "all schemes registered should pass: {stdout}"); +} diff --git a/generators/cli/sdk/tests/lib_api.rs b/generators/cli/sdk/tests/lib_api.rs index 88873a636993..18af0f15c680 100644 --- a/generators/cli/sdk/tests/lib_api.rs +++ b/generators/cli/sdk/tests/lib_api.rs @@ -4,12 +4,22 @@ #[test] fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") + fn custom_handler( + _args: &clap::ArgMatches, + _ctx: &fern_cli_sdk::openapi::AppContext, + ) -> Result<(), fern_cli_sdk::error::CliError> { + Ok(()) + } + + let app = fern_cli_sdk::app::CliApp::new("test") + .binding( + fern_cli_sdk::openapi::OpenApiBinding::new() + .spec(include_str!("../cli/openapi-fixture/openapi.yaml")) + .auth_scheme_env("bearer", "TEST_TOKEN"), + ) .command( clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), + fern_cli_sdk::openapi::OpenApiBinding::handler(custom_handler), ); // Builder chain completes without panic — the app is ready to run @@ -21,8 +31,8 @@ fn test_cli_app_builder_chain() { #[test] fn test_building_blocks_accessible() { // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); + let yaml = include_str!("../cli/openapi-fixture/openapi.yaml"); + let doc = fern_cli_sdk::openapi::load_openapi_spec(yaml, "test").unwrap(); let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); assert!(cmd.find_subcommand("users").is_some()); diff --git a/generators/cli/sdk/tests/openapi_fixture_wire.rs b/generators/cli/sdk/tests/openapi_fixture_wire.rs deleted file mode 100644 index 3604f5556e19..000000000000 --- a/generators/cli/sdk/tests/openapi_fixture_wire.rs +++ /dev/null @@ -1,2542 +0,0 @@ -/// Wire-style integration tests for the `openapi-fixture` CLI using hand-authored -/// wiremock stubs that cover the fixture spec's full operation surface. -/// -/// Architecture: -/// - `mount_mappings` loads all stubs from a small JSON file into an -/// in-process MockServer — no Docker, full per-test isolation. -/// - Tier-1 tests: point the CLI at the stub server, run a command, assert -/// the response fields appear in stdout. Auth is validated implicitly: -/// a missing Authorization header won't match the stub → 404 → test fails. -/// - Tier-2 tests: explicit `expect(1)` mocks that also verify the outgoing -/// request shape (method, path, body, query params). -mod common; - -use common::{mount_mappings, OpenApiFixtures}; -use serde_json::json; -use std::io::Write; -use std::process::Command; -use wiremock::matchers::{ - body_json, header, header_regex, method, path, query_param, query_param_is_missing, -}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const FIXTURE_MAPPINGS: &str = include_str!("fixtures/openapi-fixture-mappings.json"); - -fn fixture_cmd() -> Command { - Command::new(env!("CARGO_BIN_EXE_openapi-fixture")) -} - -fn live_cmd(server: &MockServer) -> Command { - let mut cmd = fixture_cmd(); - cmd.env("OPENAPI_FIXTURE_BASE_URL", server.uri()) - .env("OPENAPI_FIXTURE_API_KEY", OpenApiFixtures::TOKEN); - cmd -} - -async fn stubbed_server() -> MockServer { - let server = MockServer::start().await; - mount_mappings(&server, FIXTURE_MAPPINGS).await; - server -} - -// --------------------------------------------------------------------------- -// Tier 1 — GET operations: stub server validates auth + path, CLI parses response -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn test_get_file() { - let server = stubbed_server().await; - let output = live_cmd(&server) - .args(["files", "get", "--file-id", OpenApiFixtures::FILE_ID]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains(OpenApiFixtures::FILE_ID), "file id should appear in output: {stdout}"); - assert!(stdout.contains("sample.txt"), "file name from stub should appear: {stdout}"); -} - -#[tokio::test] -async fn test_get_folder() { - let server = stubbed_server().await; - let output = live_cmd(&server) - .args(["folders", "get", "--folder-id", OpenApiFixtures::FOLDER_ID]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains(OpenApiFixtures::FOLDER_ID)); -} - -#[tokio::test] -async fn test_get_current_user() { - let server = stubbed_server().await; - let output = live_cmd(&server) - .args(["users", "getCurrent"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("user"), "user type should appear in output"); -} - -#[tokio::test] -async fn test_get_user_by_id() { - let server = stubbed_server().await; - let output = live_cmd(&server) - .args(["users", "get", "--user-id", OpenApiFixtures::USER_ID]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("user"), "user type should appear in output"); -} - -#[tokio::test] -async fn test_list_users() { - let server = stubbed_server().await; - let output = live_cmd(&server) - .args(["users", "list"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[tokio::test] -async fn test_list_folder_items() { - let server = stubbed_server().await; - let output = live_cmd(&server) - .args(["folders", "listItems", "--folder-id", OpenApiFixtures::FOLDER_ID]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("sample.txt"), "file entry from stub should appear"); -} - -// --------------------------------------------------------------------------- -// Tier 2 — Request shape: explicit expect(1) mocks verify method+path+body -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn test_put_file_sends_body_and_path() { - let server = MockServer::start().await; - - Mock::given(method("PUT")) - .and(path(format!("/files/{}", OpenApiFixtures::FILE_ID))) - .and(header_regex("Authorization", "Bearer .+")) - .and(body_json(json!({"name": "renamed.txt"}))) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "id": OpenApiFixtures::FILE_ID, - "type": "file", - "name": "renamed.txt" - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "files", - "update", - "--file-id", - OpenApiFixtures::FILE_ID, - "--json", - r#"{"name":"renamed.txt"}"#, - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("renamed.txt")); -} - -#[tokio::test] -async fn test_delete_file_sends_correct_method_and_path() { - let server = MockServer::start().await; - - Mock::given(method("DELETE")) - .and(path(format!("/files/{}", OpenApiFixtures::FILE_ID))) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["files", "delete", "--file-id", OpenApiFixtures::FILE_ID]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[tokio::test] -async fn test_create_folder_with_nested_body() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/folders")) - .and(header_regex("Authorization", "Bearer .+")) - .and(body_json(json!({ - "name": "my-folder", - "parent": {"id": "0"} - }))) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({ - "id": "folder-999", - "type": "folder", - "name": "my-folder" - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "folders", - "create", - "--json", - r#"{"name":"my-folder","parent":{"id":"0"}}"#, - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("folder-999")); - assert!(stdout.contains("my-folder")); -} - -#[tokio::test] -async fn test_copy_file_post_with_nested_parent() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path(format!("/files/{}/copy", OpenApiFixtures::FILE_ID))) - .and(header_regex("Authorization", "Bearer .+")) - .and(body_json(json!({"parent": {"id": "0"}}))) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({ - "id": "file-copy-1", - "type": "file", - "name": "sample (copy).txt" - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "files", - "copy", - "--file-id", - OpenApiFixtures::FILE_ID, - "--json", - r#"{"parent":{"id":"0"}}"#, - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("file-copy-1")); -} - -#[tokio::test] -async fn test_list_users_forwards_query_params() { - // Regression: `filter_term` is exposed on the CLI as `--search-query` - // via `x-fern-parameter-name: searchQuery` in the fixture spec, but - // the wire name (`filter_term`) must still be used in the query - // string. This test pins that contract end-to-end: invoke with the - // alias, then assert the mock saw the wire name. - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users")) - .and(header_regex("Authorization", "Bearer .+")) - .and(query_param("filter_term", "alice")) - .and(query_param("user_type", "managed")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "total_count": 1, - "entries": [{"id": OpenApiFixtures::USER_ID, "type": "user", "name": "Alice"}] - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "users", - "list", - "--search-query", - "alice", - "--user-type", - "managed", - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Alice")); -} - -/// `x-fern-enum` Tier-2: when the user passes the display alias -/// (`--user-type Managed`, capital M) the executor must still send the -/// canonical **wire** value (`managed`) on the outgoing query string. -/// -/// The mock is configured with `query_param("user_type", "managed")` -/// and `expect(1)`, so: -/// - If the CLI forwarded `Managed` (the alias) instead of resolving, -/// the mock would not match → 404 → CLI exits non-zero → test fails. -/// - If the CLI didn't forward the param at all, the `expect(1)` drop -/// guard would fail at test teardown. -#[tokio::test] -async fn test_list_users_fern_enum_display_name_resolves_to_wire_value() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users")) - .and(header_regex("Authorization", "Bearer .+")) - .and(query_param("user_type", "managed")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "total_count": 1, - "entries": [ - {"id": OpenApiFixtures::USER_ID, "type": "user", "name": "Alice"} - ] - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["users", "list", "--user-type", "Managed"]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "CLI should accept the display alias and resolve it to the wire value; stderr: {}", - String::from_utf8_lossy(&output.stderr), - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("Alice"), - "expected stub response to be parsed; stdout: {stdout}", - ); -} - -#[tokio::test] -async fn test_list_users_sends_x_fern_default_but_not_schema_default() { - // Tier-2 wire test for the split between `x-fern-default` (client - // default, sent on the wire) and the OpenAPI standard `default:` - // (server-side doc hint, NOT sent on the wire). - // - // The fixture sets `x-fern-default: all` on `user_type` and a - // schema `default: 25` on `limit`. Omitting both flags must: - // * send `user_type=all` (x-fern-default is the client default) - // * NOT send `limit` at all (server applies its own default) - // - // `query_param_is_missing` enforces the negative case so a future - // regression that re-wires schema.default into clap is caught. - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users")) - .and(header_regex("Authorization", "Bearer .+")) - .and(query_param("user_type", "all")) - .and(query_param_is_missing("limit")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "total_count": 1, - "entries": [{"id": OpenApiFixtures::USER_ID, "type": "user", "name": "Alice"}] - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["users", "list"]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[tokio::test] -async fn test_list_users_user_supplied_flag_overrides_x_fern_default() { - // When the user provides `--user-type external`, the caller's value - // must win over the `x-fern-default: all` configured in the spec. - // clap's `value_source` is `CommandLine` for user-supplied flags and - // `DefaultValue` only when the user omitted the flag — the executor - // passes through the user value verbatim. - // - // `limit` stays unset on the wire because its only default is the - // OpenAPI standard `default:`, which is doc-only. - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users")) - .and(header_regex("Authorization", "Bearer .+")) - .and(query_param("user_type", "external")) - .and(query_param_is_missing("limit")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "total_count": 0, - "entries": [] - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["users", "list", "--user-type", "external"]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[tokio::test] -async fn test_list_users_user_supplied_limit_is_still_sent() { - // Sanity check on the negative-half of the split: even though the - // schema `default:` does NOT auto-send `limit=25`, an explicit - // `--limit 5` from the caller must still go through to the server. - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users")) - .and(header_regex("Authorization", "Bearer .+")) - .and(query_param("user_type", "all")) - .and(query_param("limit", "5")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "total_count": 0, - "entries": [] - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["users", "list", "--limit", "5"]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[tokio::test] -async fn test_update_folder_put_with_body() { - let server = MockServer::start().await; - - Mock::given(method("PUT")) - .and(path(format!("/folders/{}", OpenApiFixtures::FOLDER_ID))) - .and(header_regex("Authorization", "Bearer .+")) - .and(body_json(json!({"name": "renamed-folder"}))) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "id": OpenApiFixtures::FOLDER_ID, - "type": "folder", - "name": "renamed-folder" - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "folders", - "update", - "--folder-id", - OpenApiFixtures::FOLDER_ID, - "--json", - r#"{"name":"renamed-folder"}"#, - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("renamed-folder")); -} - -#[tokio::test] -async fn test_delete_folder_sends_correct_method_and_path() { - let server = MockServer::start().await; - - Mock::given(method("DELETE")) - .and(path(format!("/folders/{}", OpenApiFixtures::FOLDER_ID))) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(ResponseTemplate::new(204)) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["folders", "delete", "--folder-id", OpenApiFixtures::FOLDER_ID]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[tokio::test] -async fn test_create_user_post_with_body() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/users")) - .and(header_regex("Authorization", "Bearer .+")) - .and(body_json(json!({"name": "alice"}))) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({ - "id": "user-new", - "type": "user", - "name": "alice" - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["users", "create", "--json", r#"{"name":"alice"}"#]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("alice")); -} - -// --------------------------------------------------------------------------- -// Tier 2 — deepObject query parameter serialization -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn test_deep_object_query_param() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/search")) - .and(header_regex("Authorization", "Bearer .+")) - .and(query_param("filter[status]", "active")) - .and(query_param("filter[type]", "archived")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "results": [] - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "search", - "query", - "--filter", - r#"{"status":"active","type":"archived"}"#, - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[tokio::test] -async fn test_deep_object_query_param_nested() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/search")) - .and(header_regex("Authorization", "Bearer .+")) - .and(query_param("filter[meta][created_at]", "today")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "results": [] - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "search", - "query", - "--filter", - r#"{"meta":{"created_at":"today"}}"#, - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -// --------------------------------------------------------------------------- -// x-fern-ignore — operations and parameters marked with the extension must -// disappear from the CLI surface entirely. -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn test_x_fern_ignore_operation_is_unreachable_via_clap() { - // The fixture spec marks `DELETE /users/{user_id}` (method `hardDelete`) - // with `x-fern-ignore: true`. Clap must reject the invocation as an - // unrecognized subcommand — never reach the mock server. - let server = MockServer::start().await; - - // Defensive guard: if the CLI ever did dispatch this, we'd notice. The - // expect(0) is implicit (no mounted mock matches DELETE), so any HTTP - // request would 404 and we'd still fail below — but we also assert on - // the error text below to make the failure mode unambiguous. - let output = live_cmd(&server) - .args(["users", "hardDelete", "--user-id", OpenApiFixtures::USER_ID]) - .output() - .unwrap(); - - assert!( - !output.status.success(), - "ignored operation must not run; stdout={}, stderr={}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("unrecognized subcommand") - || stderr.contains("invalid subcommand") - || stderr.contains("error:"), - "expected clap to reject the ignored op, got stderr: {stderr}" - ); -} - -#[tokio::test] -async fn test_x_fern_ignore_non_ignored_sibling_still_works() { - // The `users` group has both an ignored op (`hardDelete`) and several - // non-ignored siblings (`list`, `create`, `get`, `getCurrent`). The - // group itself must still resolve, and the non-ignored siblings must - // still dispatch correctly. - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users")) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "total_count": 1, - "entries": [{"id": OpenApiFixtures::USER_ID, "type": "user", "name": "Alice"}] - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["users", "list"]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "non-ignored sibling must still work; stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Alice"), "sibling response should appear in stdout: {stdout}"); -} - -#[tokio::test] -async fn test_x_fern_ignore_parameter_is_unreachable_via_clap() { - // The `users get` operation has a `legacy_flag` query parameter marked - // `x-fern-ignore: true`. It must not be registered as a CLI flag, so - // passing `--legacy-flag foo` must be rejected by clap. - let server = MockServer::start().await; - - let output = live_cmd(&server) - .args([ - "users", - "get", - "--user-id", - OpenApiFixtures::USER_ID, - "--legacy-flag", - "anything", - ]) - .output() - .unwrap(); - - assert!( - !output.status.success(), - "ignored parameter must not be a registered flag; stdout={}, stderr={}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("unexpected argument") - || stderr.contains("unrecognized") - || stderr.contains("error:"), - "expected clap to reject the ignored param flag, got stderr: {stderr}" - ); -} - -// --------------------------------------------------------------------------- -// x-fern-parameter-name — the alias renames the CLI flag, but the wire -// (query string / header name) must keep the original parameter name. -// This is the inverse of x-fern-ignore: x-fern-ignore drops the surface -// entirely, x-fern-parameter-name *renames* it but is invisible to the -// server. The two tests below pin both halves of that contract. -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn test_x_fern_parameter_name_query_param_uses_wire_name() { - // The fixture spec aliases `filter_term` (wire) to `searchQuery` on - // the CLI surface. Invoking `--search-query alice` must produce a - // request whose query string carries `filter_term=alice`, not - // `search-query=alice` or `searchQuery=alice` — the alias is purely - // a CLI-side rename. - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users")) - .and(header_regex("Authorization", "Bearer .+")) - // Wire-name assertion: the renamed flag still serializes as - // `filter_term` on the wire. - .and(query_param("filter_term", "alice")) - // Negative guard: ensure the alias is NOT being sent on the wire - // under any of the obvious spellings. - .and(query_param_is_missing("searchQuery")) - .and(query_param_is_missing("search-query")) - .and(query_param_is_missing("search_query")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "total_count": 1, - "entries": [{"id": OpenApiFixtures::USER_ID, "type": "user", "name": "Alice"}] - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["users", "list", "--search-query", "alice"]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "wire request via aliased flag should succeed; stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Alice"), "response should reach stdout: {stdout}"); -} - -#[tokio::test] -async fn test_x_fern_parameter_name_header_uses_wire_name() { - // Mirrors the canonical FER-9864 example: the header parameter is - // wired as `X-Fern-Version` but exposed on the CLI as - // `--api-version` (kebab of the `apiVersion` alias). The outgoing - // HTTP request must carry an `X-Fern-Version` header, not an - // `apiVersion` header — the alias is invisible to the server. - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users")) - .and(header_regex("Authorization", "Bearer .+")) - // Wire-name assertion: the header keeps its original name. - .and(header("X-Fern-Version", "2024-01-01")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "total_count": 0, - "entries": [] - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["users", "list", "--api-version", "2024-01-01"]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "aliased header flag should reach the server with the wire-name header; stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -// --------------------------------------------------------------------------- -// Binary-body uploads — wire-level transfer-encoding contract -// -// The CLI accepts `--file `, `--file @`, or `--file -` (stdin). -// Disk paths must send `Content-Length`; stdin must send `Transfer-Encoding: -// chunked` (no Content-Length). These tests inspect the captured request -// after the fact so absence of a header is a real assertion, not "any regex". -// `header()` (exact match) is used over `header_regex` so the assertion locks -// the wire shape exactly — a future reqwest that appended `; charset=foo` -// would surface here. -// --------------------------------------------------------------------------- - -/// Run `files upload` and return the single captured request, asserting that -/// the response was 2xx. Shared by every binary-body test below. -async fn run_upload_and_capture( - server: &MockServer, - configure: F, -) -> wiremock::Request -where - F: FnOnce(Command) -> std::process::Output, -{ - Mock::given(method("POST")) - .and(path("/files/upload")) - .and(header_regex("Authorization", "Bearer .+")) - .and(header("content-type", "application/octet-stream")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "upload-ok"}))) - .expect(1) - .mount(server) - .await; - - let mut cmd = live_cmd(server); - cmd.args(["files", "upload"]); - let output = configure(cmd); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let mut reqs = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(reqs.len(), 1); - reqs.pop().unwrap() -} - -/// Pipe `payload` through stdin to the upload command and wait for completion. -fn run_upload_from_stdin(mut cmd: Command, payload: &[u8]) -> std::process::Output { - let mut child = cmd - .args(["--file", "-"]) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .unwrap(); - child - .stdin - .as_mut() - .expect("stdin pipe") - .write_all(payload) - .unwrap(); - drop(child.stdin.take()); // close so the CLI sees EOF - child.wait_with_output().unwrap() -} - -#[tokio::test] -async fn test_upload_from_file_sends_content_length_no_chunked() { - let server = MockServer::start().await; - let tmp = tempfile::NamedTempFile::new().unwrap(); - let payload = b"hello-world-binary-body"; - std::fs::write(tmp.path(), payload).unwrap(); - - let req = run_upload_and_capture(&server, |mut cmd| { - cmd.arg("--file").arg(tmp.path()).output().unwrap() - }) - .await; - - let content_length = req - .headers - .get("content-length") - .map(|v| v.to_str().unwrap_or("").to_string()); - assert_eq!( - content_length.as_deref(), - Some(payload.len().to_string().as_str()), - "file uploads must send a Content-Length matching the file size" - ); - assert!( - req.headers.get("transfer-encoding").is_none(), - "file uploads must NOT use chunked transfer encoding" - ); - assert_eq!(req.body, payload, "body must equal file contents"); -} - -#[tokio::test] -async fn test_upload_from_at_path_matches_plain_path_wire_shape() { - // `@` is curl-style sugar. It must produce the exact same wire - // shape as plain `` — Content-Length set, no chunked, body = file. - let server = MockServer::start().await; - let tmp = tempfile::NamedTempFile::new().unwrap(); - let payload = b"curl-prefixed-path-body"; - std::fs::write(tmp.path(), payload).unwrap(); - - let req = run_upload_and_capture(&server, |mut cmd| { - let arg = format!("@{}", tmp.path().display()); - cmd.args(["--file", &arg]).output().unwrap() - }) - .await; - - let content_length = req - .headers - .get("content-length") - .map(|v| v.to_str().unwrap_or("").to_string()); - assert_eq!( - content_length.as_deref(), - Some(payload.len().to_string().as_str()), - "@ uploads must send a Content-Length matching the file size" - ); - assert!( - req.headers.get("transfer-encoding").is_none(), - "@ uploads must NOT use chunked transfer encoding" - ); - assert_eq!(req.body, payload, "body must equal file contents"); -} - -#[tokio::test] -async fn test_upload_from_stdin_sends_chunked_no_content_length() { - let server = MockServer::start().await; - let payload = b"streamed-from-stdin-bytes"; - - let req = run_upload_and_capture(&server, |cmd| run_upload_from_stdin(cmd, payload)).await; - - assert!( - req.headers.get("content-length").is_none(), - "stdin uploads must NOT send Content-Length" - ); - let transfer_encoding = req - .headers - .get("transfer-encoding") - .map(|v| v.to_str().unwrap_or("").to_string()); - assert_eq!( - transfer_encoding.as_deref(), - Some("chunked"), - "stdin uploads must use Transfer-Encoding: chunked" - ); - assert_eq!(req.body, payload, "body must equal piped stdin bytes"); -} - -#[tokio::test] -async fn test_upload_from_empty_stdin_still_uses_chunked() { - // Zero-byte stdin must still flow through the chunked path — the choice - // of transfer encoding is driven by "do we know the length up front", - // not by "is the body non-empty". A regression that special-cased empty - // stdin (e.g. fell back to Content-Length: 0) would surface here. - let server = MockServer::start().await; - - let req = run_upload_and_capture(&server, |cmd| run_upload_from_stdin(cmd, b"")).await; - - assert!( - req.headers.get("content-length").is_none(), - "empty-stdin uploads must NOT send Content-Length" - ); - let transfer_encoding = req - .headers - .get("transfer-encoding") - .map(|v| v.to_str().unwrap_or("").to_string()); - assert_eq!( - transfer_encoding.as_deref(), - Some("chunked"), - "empty-stdin uploads must still use Transfer-Encoding: chunked" - ); - assert!(req.body.is_empty(), "body must be empty"); -} - -// --------------------------------------------------------------------------- -// Error handling -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn test_404_exits_nonzero_with_error_detail() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/files/does-not-exist")) - .respond_with(ResponseTemplate::new(404).set_body_json(json!({ - "type": "error", - "status": 404, - "code": "not_found", - "message": "The resource could not be found." - }))) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["files", "get", "--file-id", "does-not-exist"]) - .output() - .unwrap(); - - assert!(!output.status.success(), "CLI should exit non-zero on 404"); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("not_found") || stdout.contains("404") || stdout.contains("not found"), - "error detail should appear in output: {stdout}" - ); -} - -#[tokio::test] -async fn test_500_exits_nonzero() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users/me")) - .respond_with(ResponseTemplate::new(500).set_body_json(json!({ - "type": "error", - "status": 500, - "code": "internal_server_error", - "message": "Internal Server Error" - }))) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["users", "getCurrent"]) - .output() - .unwrap(); - - assert!(!output.status.success(), "CLI should exit non-zero on 500"); -} - -#[tokio::test] -async fn test_401_exits_nonzero_with_auth_error() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users/me")) - .respond_with(ResponseTemplate::new(401).set_body_json(json!({ - "type": "error", - "status": 401, - "code": "unauthorized", - "message": "Authentication failed." - }))) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["users", "getCurrent"]) - .output() - .unwrap(); - - assert!(!output.status.success(), "CLI should exit non-zero on 401"); -} - -// --------------------------------------------------------------------------- -// Tier 2 — per-operation `x-fern-pagination` -// -// `/events` declares an explicit cursor-style block: -// cursor: $request.next_marker -// next_cursor: $response.next_marker -// results: $response.entries -// -// This test proves the CLI follows the per-op config rather than the -// document-level heuristic (which keys off `pageToken` / `nextPageToken`). -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn test_per_op_cursor_pagination_follows_next_marker() { - let server = MockServer::start().await; - - // First page: served once. wiremock's `up_to_n_times(1)` retires the - // mock after one match so the second request can't hit it (otherwise - // the unscoped path matcher would swallow the next_marker request too). - Mock::given(method("GET")) - .and(path("/events")) - .and(header_regex("Authorization", "Bearer .*")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "entries": [ - { "id": "evt-1", "name": "first" }, - { "id": "evt-2", "name": "second" } - ], - "next_marker": "marker-page-2" - }))) - .up_to_n_times(1) - .expect(1) - .mount(&server) - .await; - - // Second page: keyed on the per-op cursor parameter name (`next_marker`), - // NOT the heuristic `pageToken`. Stub returns final entries and empty - // next_marker to terminate the loop. - Mock::given(method("GET")) - .and(path("/events")) - .and(query_param("next_marker", "marker-page-2")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "entries": [ - { "id": "evt-3", "name": "third" } - ], - "next_marker": "" - }))) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["events", "list", "--page-all"]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("evt-1"), "first page should appear: {stdout}"); - assert!(stdout.contains("evt-2"), "first page should appear: {stdout}"); - assert!( - stdout.contains("evt-3"), - "second page driven by x-fern-pagination should appear: {stdout}" - ); -} - -#[tokio::test] -async fn test_per_op_pagination_respects_no_paginate_flag() { - let server = MockServer::start().await; - - // First page is served; a second-page mock is registered but is asserted - // to NEVER be called when `--page-all` is omitted, even though the per-op - // config is in place and the response includes a next_marker. - Mock::given(method("GET")) - .and(path("/events")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "entries": [{ "id": "evt-only", "name": "single" }], - "next_marker": "would-be-page-2" - }))) - .expect(1) - .mount(&server) - .await; - - Mock::given(method("GET")) - .and(path("/events")) - .and(query_param("next_marker", "would-be-page-2")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "entries": [], - "next_marker": "" - }))) - .expect(0) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["events", "list"]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("evt-only")); -} - -// `/audit` declares an explicit offset-style block: -// offset: $request.offset -// results: $response.entries -// step: $request.limit -// -// When `step` is wired, the executor gates the next page on whether a -// *full* page came back — matching upstream fern's `items.length >= step` -// check. The caller passes `--limit 10`; the server returns a short page -// of 3 rows. The wire test asserts the executor recognizes the short -// page and stops, issuing exactly one HTTP request (no offset=3 follow-up). -#[tokio::test] -async fn test_per_op_offset_pagination_step_stops_on_short_page() { - let server = MockServer::start().await; - - // Single request expected. The caller asked for limit=10; the server - // returns 3 rows. Because `step` is wired, the executor's full-page - // gate (`3 < 10`) ends pagination — no second request. - Mock::given(method("GET")) - .and(path("/audit")) - .and(query_param("limit", "10")) - .and(query_param_is_missing("offset")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "entries": [ - { "id": "a-1" }, - { "id": "a-2" }, - { "id": "a-3" } - ] - }))) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "audit", - "list", - "--page-all", - "--params", - r#"{"limit": 10}"#, - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -// Same `/audit` endpoint, but the server returns a *full* page (limit -// items). The executor advances the offset by `len(items)` (item-index -// semantics) and fetches the next page. Verifies that `step` only gates -// the "did we get a full page?" check — it is never used as the -// increment amount. -#[tokio::test] -async fn test_per_op_offset_pagination_step_continues_on_full_page() { - let server = MockServer::start().await; - - // First page: 3 items returned for limit=3 → full page → continue. - Mock::given(method("GET")) - .and(path("/audit")) - .and(query_param("limit", "3")) - .and(query_param_is_missing("offset")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "entries": [ - { "id": "a-1" }, - { "id": "a-2" }, - { "id": "a-3" } - ] - }))) - .up_to_n_times(1) - .expect(1) - .mount(&server) - .await; - - // Second page: offset=3 (advance by len(items), not by step), still - // limit=3. Server returns a short page → stop. - Mock::given(method("GET")) - .and(path("/audit")) - .and(query_param("limit", "3")) - .and(query_param("offset", "3")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "entries": [ - { "id": "a-4" } - ] - }))) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "audit", - "list", - "--page-all", - "--params", - r#"{"limit": 3}"#, - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - - -// --------------------------------------------------------------------------- -// x-fern-idempotency-headers (FER-9864 P1) — the synthesized -// `--idempotency-key` flag must land as an HTTP `Idempotency-Key` header on -// idempotent ops only. -// --------------------------------------------------------------------------- - -/// On `POST /payments` (`x-fern-idempotent: true`), the value passed via -/// `--idempotency-key` is sent as the `Idempotency-Key` HTTP header. -#[tokio::test] -async fn test_idempotent_op_sends_idempotency_header() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/payments")) - .and(header_regex("Authorization", "Bearer .+")) - .and(header("idempotency-key", "test-idem-key-001")) - .and(body_json(json!({"amount": 1000, "currency": "USD"}))) - .respond_with( - ResponseTemplate::new(201) - .set_body_json(json!({"id": "pay-1", "amount": 1000, "currency": "USD"})), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "payments", - "create", - "--idempotency-key", - "test-idem-key-001", - "--json", - r#"{"amount":1000,"currency":"USD"}"#, - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - - // Defensive assertion on the captured request — the mock's - // `header(...)` already matches on `Idempotency-Key`, but reading the - // header back from the captured request locks the wire shape so a - // future change that, e.g., started sending it lowercased shows up - // here unambiguously. - let reqs = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(reqs.len(), 1); - let sent = reqs[0] - .headers - .get("idempotency-key") - .map(|v| v.to_str().unwrap_or("").to_string()); - assert_eq!(sent.as_deref(), Some("test-idem-key-001")); -} - -/// Twin assertion: on the non-idempotent sibling `GET /payments`, the -/// `Idempotency-Key` flag is not surfaced by clap and the header is not -/// sent on the wire even if the user tried to bypass clap. We assert -/// clap rejects the flag with an "unrecognized" error AND that the -/// happy-path invocation produces no `Idempotency-Key` header. -#[tokio::test] -async fn test_non_idempotent_sibling_rejects_flag_and_omits_header() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/payments")) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({ - "total_count": 0, - "entries": [] - })), - ) - .expect(1) - .mount(&server) - .await; - - // (1) Clap rejects the flag on the non-idempotent op. - let reject = live_cmd(&server) - .args([ - "payments", - "list", - "--idempotency-key", - "should-not-be-accepted", - ]) - .output() - .unwrap(); - assert!( - !reject.status.success(), - "non-idempotent op must reject --idempotency-key; stdout={} stderr={}", - String::from_utf8_lossy(&reject.stdout), - String::from_utf8_lossy(&reject.stderr) - ); - let reject_err = String::from_utf8_lossy(&reject.stderr); - assert!( - reject_err.contains("unexpected argument") - || reject_err.contains("unrecognized argument") - || reject_err.contains("unknown argument") - || reject_err.contains("error:"), - "expected clap to reject the flag, got: {reject_err}" - ); - - // (2) Happy path: same op without the flag works AND no - // `Idempotency-Key` header is on the wire. - let output = live_cmd(&server) - .args(["payments", "list"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let reqs = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(reqs.len(), 1, "exactly one GET /payments"); - assert!( - reqs[0].headers.get("idempotency-key").is_none(), - "non-idempotent op must not send Idempotency-Key on the wire", - ); -} - -/// `IdempotencyHeader { header: X-Trace-Id, name: trace_id }` -/// materializes as `--trace-id` on the idempotent op, not -/// `--x-trace-id`. The flag value is sent as the `X-Trace-Id` HTTP -/// header on the wire. Exercises the `flag_name_override` path that -/// matches the upstream Fern OpenAPI importer's SDK parameter naming. -#[tokio::test] -async fn test_idempotency_header_name_drives_flag_x_trace_id_is_sent() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/payments")) - .and(header_regex("Authorization", "Bearer .+")) - .and(header("x-trace-id", "trace-abc-123")) - .and(body_json(json!({"amount": 500, "currency": "EUR"}))) - .respond_with( - ResponseTemplate::new(201) - .set_body_json(json!({"id": "pay-2", "amount": 500, "currency": "EUR"})), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "payments", - "create", - "--trace-id", - "trace-abc-123", - "--json", - r#"{"amount":500,"currency":"EUR"}"#, - ]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stdout: {} stderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - - // Sanity check: `--x-trace-id` (the buggy pre-override flag name) - // is NOT exposed. clap must reject it. - let reject = live_cmd(&server) - .args([ - "payments", - "create", - "--x-trace-id", - "trace-abc-123", - "--json", - r#"{"amount":500,"currency":"EUR"}"#, - ]) - .output() - .unwrap(); - assert!( - !reject.status.success(), - "the legacy `--x-trace-id` form must NOT be exposed", - ); -} - -// --------------------------------------------------------------------------- -// x-fern-sdk-return-value — operation-level response extraction -// -// The fixture stubs `/reports` to return a `{ data: [...], meta: {...} }` -// envelope. The operation declares `x-fern-sdk-return-value: data`, so by -// default the CLI must print only the `data` array. `--no-extract` is the -// documented escape hatch and must print the full body. -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn test_return_value_extracts_top_level_data_by_default() { - let server = stubbed_server().await; - let output = live_cmd(&server) - .args(["reports", "list"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - // `data` itself isn't a key in the printed JSON anymore — only its - // contents. The envelope's `meta` block must not leak through. - assert!(stdout.contains("rpt-1"), "extracted data should appear: {stdout}"); - assert!(stdout.contains("rpt-2"), "extracted data should appear: {stdout}"); - assert!( - !stdout.contains("\"meta\""), - "envelope's `meta` must not be printed when return_value extracts `data`: {stdout}", - ); - assert!( - !stdout.contains("\"data\""), - "the `data` key itself must be stripped (only its contents are surfaced): {stdout}", - ); -} - -#[tokio::test] -async fn test_return_value_no_extract_prints_full_envelope() { - let server = stubbed_server().await; - let output = live_cmd(&server) - .args(["reports", "list", "--no-extract"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - // Full envelope: both `data` and `meta` must be present. - assert!( - stdout.contains("\"data\""), - "--no-extract should surface the full envelope including `data`: {stdout}", - ); - assert!( - stdout.contains("\"meta\""), - "--no-extract should surface the full envelope including `meta`: {stdout}", - ); - assert!(stdout.contains("rpt-1"), "items should still be visible: {stdout}"); -} - -#[tokio::test] -async fn test_return_value_extracts_nested_dot_path() { - let server = stubbed_server().await; - let output = live_cmd(&server) - .args(["reports", "getStats"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - stdout.contains("\"value\"") && stdout.contains("42"), - "nested `result.value` extraction should surface the inner object: {stdout}", - ); - assert!( - stdout.contains("\"unit\"") && stdout.contains("reports"), - "sibling fields of the extracted subobject are preserved: {stdout}", - ); - assert!( - !stdout.contains("\"server_time\""), - "`meta.server_time` lives outside `result` and must not appear: {stdout}", - ); - assert!( - !stdout.contains("\"result\""), - "the `result` wrapper key itself must be stripped: {stdout}", - ); -} - -#[tokio::test] -async fn test_return_value_paginated_emits_subvalue_per_page() { - // Composes `x-fern-sdk-return-value: data` with cursor pagination - // where `next_cursor: $response.next` lives *outside* the extracted - // `data` subvalue. Each `--page-all` page must surface only the - // extracted array, while the continuation cursor is still read from - // the full envelope. The fixture op declares both extensions. - let server = MockServer::start().await; - - // Page 1: no cursor on request, returns `next: "page-2"`. - Mock::given(method("GET")) - .and(path("/reports/paged")) - .and(header_regex("Authorization", "Bearer .+")) - .and(wiremock::matchers::query_param_is_missing("cursor")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "data": [ - { "id": "rpt-1", "name": "Q1" }, - { "id": "rpt-2", "name": "Q2" } - ], - "next": "page-2" - }))) - .expect(1) - .mount(&server) - .await; - - // Page 2: cursor=page-2 on request, returns no `next` → loop stops. - Mock::given(method("GET")) - .and(path("/reports/paged")) - .and(header_regex("Authorization", "Bearer .+")) - .and(wiremock::matchers::query_param("cursor", "page-2")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "data": [ - { "id": "rpt-3", "name": "Q3" } - ] - }))) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["reports", "listPaged", "--page-all"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - - // All three records came through across the two pages. - assert!(stdout.contains("rpt-1"), "page 1 item missing: {stdout}"); - assert!(stdout.contains("rpt-2"), "page 1 item missing: {stdout}"); - assert!(stdout.contains("rpt-3"), "page 2 item missing: {stdout}"); - - // The envelope-level cursor field `next` itself must not be printed - // — extraction stripped it before emit. If it leaks here, the - // pagination path was reading the extracted subvalue (broken). - assert!( - !stdout.contains("\"next\""), - "envelope `next` cursor field must not appear in extracted output: {stdout}", - ); - // And the `data` wrapper key is also stripped per page. - assert!( - !stdout.contains("\"data\""), - "`data` wrapper key must not appear in extracted output: {stdout}", - ); -} - -#[tokio::test] -async fn test_return_value_unresolved_path_errors() { - // Configure the server to return a body that does NOT contain the - // promised `data` field. The CLI must fail loudly rather than - // silently printing `null` or the full body. - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/reports")) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "unexpected": "shape", - "meta": {"total": 0} - }))) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["reports", "list"]) - .output() - .unwrap(); - assert!( - !output.status.success(), - "command should fail when return_value path can't be resolved; stdout: {} / stderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("'data'"), - "error should name the missing path: {stderr}", - ); - // The operationId is set on the fixture op (`reports_list`), so it - // must appear by name in the error message. - assert!( - stderr.contains("reports_list"), - "error should name the operation id: {stderr}", - ); - assert!( - stderr.contains("--no-extract"), - "error should point users at the --no-extract escape hatch: {stderr}", - ); -} - -// --------------------------------------------------------------------------- -// Tier-2 — `x-fern-sdk-variables` substitution on the wire -// -// These tests prove the runtime contract: a value resolved from the global -// `--garden-id` flag (or its `$GARDEN_ID` env-var fallback) must reach the -// outgoing HTTP request as the substituted path segment, with no `{gardenId}` -// placeholder leaking through. Each test mounts an `expect(1)` mock with an -// exact `path()` match — if substitution is wrong, no request matches the -// mock and the assertion drop-time check fails. -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn test_sdk_variable_cli_flag_substitutes_outgoing_path() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/gardens/my-garden/zones")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .env_remove("GARDEN_ID") - .args(["--garden-id", "my-garden", "zones", "list"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - // `expect(1)` verified on Mock drop. -} - -#[tokio::test] -async fn test_sdk_variable_env_var_substitutes_outgoing_path() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/gardens/fromenv/zones")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .env("GARDEN_ID", "fromenv") - .args(["zones", "list"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[tokio::test] -async fn test_sdk_variable_cli_flag_overrides_env_var_outgoing_path() { - // Resolution order: CLI flag > env var. The wire must see the flag's value - // (`fromflag`), not the env var's (`fromenv`). - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/gardens/fromflag/zones")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .env("GARDEN_ID", "fromenv") - .args(["--garden-id", "fromflag", "zones", "list"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -#[tokio::test] -async fn test_sdk_variable_missing_aborts_before_request() { - // No flag, no env var — the CLI must short-circuit with a validation - // error before any HTTP traffic. `expect(0)` proves nothing hit the wire. - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/gardens/{gardenId}/zones")) - .respond_with(ResponseTemplate::new(200)) - .expect(0) - .mount(&server) - .await; - - let output = live_cmd(&server) - .env_remove("GARDEN_ID") - .args(["zones", "list"]) - .output() - .unwrap(); - assert!( - !output.status.success(), - "expected CLI failure when variable is unset; stdout: {} stderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - let combined = format!( - "{}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - assert!( - combined.contains("--garden-id") && combined.contains("GARDEN_ID"), - "error should name both --garden-id and GARDEN_ID: {combined}" - ); -} - -// --------------------------------------------------------------------------- -// Tier 2 — x-fern-retries (FER-9864 P2) -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn test_retries_get_recovers_after_transient_5xx() { - // `users_list` is annotated `x-fern-retries` in the fixture spec. - // First stub returns 503 once; second stub returns 200. The CLI - // must transparently retry the GET and surface the 200 response. - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users")) - .respond_with(ResponseTemplate::new(503)) - .up_to_n_times(1) - .expect(1) - .mount(&server) - .await; - - Mock::given(method("GET")) - .and(path("/users")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([ - {"id": "user-1", "name": "Ada"} - ]))) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["users", "list"]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "expected CLI to retry the 503 and surface the 200; stderr: {}", - String::from_utf8_lossy(&output.stderr), - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Ada"), "200 body should appear: {stdout}"); -} - -#[tokio::test] -async fn test_retries_get_respects_retry_after_numeric_header() { - // Wire-level check that the executor honors `Retry-After: `. - // We can't observe the wall-clock delay deterministically across - // CI nodes, but the *retry happens* — `expect(1)` then `expect(1)` - // verifies the CLI did two sends total and consumed the 200. - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users")) - .respond_with( - ResponseTemplate::new(429).insert_header("retry-after", "0"), - ) - .up_to_n_times(1) - .expect(1) - .mount(&server) - .await; - - Mock::given(method("GET")) - .and(path("/users")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!([]))) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["users", "list"]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "expected CLI to honor Retry-After and surface the 200; stderr: {}", - String::from_utf8_lossy(&output.stderr), - ); -} - -#[tokio::test] -async fn test_retries_post_without_idempotent_does_not_retry() { - // `users_create` declares `x-fern-retries` but is a POST without - // `x-fern-idempotent`. Per the FER-9864 P2 per-method policy, the - // executor must NOT retry — the server may have processed the - // request and a retry would double-post. We assert `expect(1)` - // so any retry would fail the test. - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/users")) - .respond_with(ResponseTemplate::new(503)) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["users", "create", "--json", r#"{"name":"Ada"}"#]) - .output() - .unwrap(); - - assert!( - !output.status.success(), - "expected POST to surface the 503 without retrying; stdout: {} stderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); -} - -#[tokio::test] -async fn test_retries_no_retry_flag_short_circuits_get_retries() { - // `--no-retry` is the debug-only opt-out. With it set, even a - // GET on an op with `x-fern-retries` must NOT retry — the CLI - // surfaces the first failure for the user to inspect. - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users")) - .respond_with(ResponseTemplate::new(503)) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args(["users", "list", "--no-retry"]) - .output() - .unwrap(); - - assert!( - !output.status.success(), - "expected --no-retry to surface the 503 immediately; stdout: {} stderr: {}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); -} - -// --------------------------------------------------------------------------- -// x-fern-global-headers (FER-9864 P2). The fixture spec declares: -// -// x-fern-global-headers: -// - header: X-API-Stage -// name: apiStage -// env: FIXTURE_API_STAGE -// default: "production" -// - header: X-Tenant-Id -// optional: true -// -// These tests exercise the resolution chain (CLI > env > default), the -// stamping behavior across distinct operations (a global header is by -// definition not tied to one route), and the optional case where the -// header is omitted when no source supplies a value. -// --------------------------------------------------------------------------- - -/// Spec-level default ("production") is sent verbatim on `GET /users/me` -/// when neither a flag nor `$FIXTURE_API_STAGE` is provided. Pins the -/// "default wins when nothing else is set" leg of the resolution chain -/// against a real outgoing request. -#[tokio::test] -async fn test_global_header_default_is_sent_on_outgoing_request() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users/me")) - .and(header_regex("Authorization", "Bearer .+")) - .and(header("X-API-Stage", "production")) - .respond_with( - ResponseTemplate::new(200).set_body_json(json!({"id": "me", "name": "Alice"})), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .env_remove("FIXTURE_API_STAGE") - .args(["users", "getCurrent"]) - .output() - .unwrap(); - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -/// The CLI flag `--api-stage` wins over the env var, and the env var -/// wins over the spec-level default. Drives the three cases against one -/// mock server to pin precedence end-to-end. -#[tokio::test] -async fn test_global_header_resolution_order_cli_over_env_over_default() { - let server = MockServer::start().await; - - // Case 1: only the spec default applies → "production" on the wire. - Mock::given(method("GET")) - .and(path("/users/me")) - .and(header("X-API-Stage", "production")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "me"}))) - .expect(1) - .mount(&server) - .await; - let out = live_cmd(&server) - .env_remove("FIXTURE_API_STAGE") - .args(["users", "getCurrent"]) - .output() - .unwrap(); - assert!(out.status.success(), "default case: stderr={}", String::from_utf8_lossy(&out.stderr)); - - // Case 2: env wins over default → "staging" on the wire. - Mock::given(method("GET")) - .and(path("/users/me")) - .and(header("X-API-Stage", "staging")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "me"}))) - .expect(1) - .mount(&server) - .await; - let out = live_cmd(&server) - .env("FIXTURE_API_STAGE", "staging") - .args(["users", "getCurrent"]) - .output() - .unwrap(); - assert!(out.status.success(), "env case: stderr={}", String::from_utf8_lossy(&out.stderr)); - - // Case 3: CLI flag wins over both env and default → "canary". - Mock::given(method("GET")) - .and(path("/users/me")) - .and(header("X-API-Stage", "canary")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "me"}))) - .expect(1) - .mount(&server) - .await; - let out = live_cmd(&server) - .env("FIXTURE_API_STAGE", "staging") - .args(["users", "getCurrent", "--api-stage", "canary"]) - .output() - .unwrap(); - assert!(out.status.success(), "cli case: stderr={}", String::from_utf8_lossy(&out.stderr)); -} - -/// "Global" means stamped on every operation, not just one. Issue two -/// different operations in a single test run (a GET on `users` and a -/// POST on `payments`) and verify both carry the same global header. -/// Without the global-header registration, the second mock would 404 -/// because no `X-API-Stage` matcher would hit. -#[tokio::test] -async fn test_global_header_is_stamped_on_multiple_operations() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users/me")) - .and(header("X-API-Stage", "canary")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "me"}))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path("/payments")) - .and(header("X-API-Stage", "canary")) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({"id": "pay-1", "amount": 1, "currency": "USD"})), - ) - .expect(1) - .mount(&server) - .await; - - let get_out = live_cmd(&server) - .args(["users", "getCurrent", "--api-stage", "canary"]) - .output() - .unwrap(); - assert!( - get_out.status.success(), - "users.getCurrent stderr: {}", - String::from_utf8_lossy(&get_out.stderr) - ); - let post_out = live_cmd(&server) - .args([ - "payments", - "create", - "--api-stage", - "canary", - "--idempotency-key", - "k-1", - "--json", - r#"{"amount":1,"currency":"USD"}"#, - ]) - .output() - .unwrap(); - assert!( - post_out.status.success(), - "payments.create stderr: {}", - String::from_utf8_lossy(&post_out.stderr) - ); -} - -/// An optional global header with no env, no default, and no flag is -/// not stamped on the outgoing request. Verifies the negative case: the -/// stub does not match on `X-Tenant-Id` and the absence of the header -/// is pinned via the captured request after the mock has fired. -#[tokio::test] -async fn test_optional_global_header_omitted_when_not_supplied() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users/me")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "me"}))) - .expect(1) - .mount(&server) - .await; - - let out = live_cmd(&server) - .env_remove("FIXTURE_API_STAGE") - .args(["users", "getCurrent"]) - .output() - .unwrap(); - assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); - - let reqs = server.received_requests().await.expect("requests recorded"); - assert_eq!(reqs.len(), 1, "exactly one outgoing request"); - assert!( - reqs[0].headers.get("X-Tenant-Id").is_none(), - "optional global header with no source must NOT be sent on the wire" - ); -} - -/// When the user supplies `--tenant-id` (derived from `name: tenantId` -/// on the optional `X-Tenant-Id` entry), the header lands on the wire -/// under its original wire-name. Companion to the omitted-when-not-supplied -/// test above; pins the `name:` → kebab-cased flag mapping. -#[tokio::test] -async fn test_optional_global_header_sent_when_flag_supplied() { - let server = MockServer::start().await; - - Mock::given(method("GET")) - .and(path("/users/me")) - .and(header("X-Tenant-Id", "tenant-42")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "me"}))) - .expect(1) - .mount(&server) - .await; - - let out = live_cmd(&server) - .args(["users", "getCurrent", "--tenant-id", "tenant-42"]) - .output() - .unwrap(); - assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); -} - -/// Passing `--api-stage ""` (empty/whitespace-only) must NOT silently -/// stamp `X-API-Stage:` on the wire — `resolve_global_header_value` -/// trims and treats empty values as "no value supplied", which then -/// triggers the required-header validation error. Pins the fix for -/// the self-review finding noted in PR #45. -#[tokio::test] -async fn test_empty_flag_value_errors_for_required_global_header() { - let server = MockServer::start().await; - // No mock registered — the request should never reach the wire. - - let out = live_cmd(&server) - .env_remove("FIXTURE_API_STAGE") - .args(["users", "getCurrent", "--api-stage", " "]) - .output() - .unwrap(); - assert!( - !out.status.success(), - "empty `--api-stage` must error, got success: {}", - String::from_utf8_lossy(&out.stdout) - ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("Missing required global header 'X-API-Stage'"), - "expected required-header error, got: {stderr}" - ); -} - -// --------------------------------------------------------------------------- -// Per-field body flags — proves the parser exposes inline-schema body fields -// as individual flags AND that the executor type-coerces and routes them to -// the JSON body (not the query string). -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn test_create_thing_with_per_field_body_flags() { - let server = MockServer::start().await; - - // Mock asserts the EXACT outgoing body shape: scalars are typed (integer, - // boolean), array fields use repeated flags, nested object is decoded from - // its JSON-string flag value. - Mock::given(method("POST")) - .and(path("/things")) - .and(header_regex("Authorization", "Bearer .+")) - .and(body_json(json!({ - "name": "widget", - "count": 7, - "is_active": true, - "tags": ["red", "blue"], - "metadata": { "owner": "alice" } - }))) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({ - "id": "thing-1", - "name": "widget" - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "things", - "create", - "--name", - "widget", - "--count", - "7", - "--is-active", - "true", - "--tags", - "red", - "--tags", - "blue", - "--metadata", - r#"{"owner":"alice"}"#, - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("thing-1")); -} - -#[tokio::test] -async fn test_json_flag_overrides_per_field_body_flags() { - // When both per-field flags and `--json` are provided, the `--json` keys - // win on overlap — mirrors how `--params` overrides individual flags. - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/things")) - .and(header_regex("Authorization", "Bearer .+")) - .and(body_json( - json!({ "name": "from-json-wins", "count": 99 }), - )) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({ - "id": "thing-2", - "name": "from-json-wins" - })), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "things", - "create", - "--name", - "from-flag-loses", - "--count", - "99", - "--json", - r#"{"name":"from-json-wins"}"#, - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("thing-2")); -} - -// --------------------------------------------------------------------------- -// FER-10435: nested body props as dot-notation flags + repeated array flags -// --------------------------------------------------------------------------- - -/// Tier-2: dot-notation flags reconstruct nested JSON. -/// --name.first Abraham --name.last Lincoln → {"name":{"first":"Abraham","last":"Lincoln"}} -#[tokio::test] -async fn test_nested_body_flags_reconstruct_nested_json() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/persons")) - .and(header_regex("Authorization", "Bearer .+")) - .and(body_json(json!({ - "name": {"first": "Abraham", "last": "Lincoln"}, - "role": "president" - }))) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({"id": "person-1"})), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "persons", - "create", - "--name.first", - "Abraham", - "--name.last", - "Lincoln", - "--role", - "president", - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("person-1")); -} - -/// Tier-2: repeated array flags accumulate into a JSON array. -/// --tag admin --tag reviewer → {"tag":["admin","reviewer"]} -#[tokio::test] -async fn test_repeated_array_flags_accumulate() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/articles")) - .and(header_regex("Authorization", "Bearer .+")) - .and(body_json(json!({ - "title": "Hello World", - "tag": ["admin", "reviewer"] - }))) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({"id": "article-1"})), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "articles", - "create", - "--title", - "Hello World", - "--tag", - "admin", - "--tag", - "reviewer", - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("article-1")); -} - -/// Tier-2: $ref request body is resolved from components/schemas and its -/// properties are flattened as per-field flags. -/// --label Sprocket --priority 5 → {"label":"Sprocket","priority":5} -#[tokio::test] -async fn test_ref_body_properties_flattened_as_flags() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/widgets")) - .and(header_regex("Authorization", "Bearer .+")) - .and(body_json(json!({ - "label": "Sprocket", - "priority": 5 - }))) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({"id": "widget-1"})), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "widgets", - "create", - "--label", - "Sprocket", - "--priority", - "5", - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("widget-1")); -} - -/// Tier-2: $ref property within an inline body schema is resolved and its fields -/// surface as dot-notation flags. -/// --address.city SF --address.zip 94105 → {"address":{"city":"SF","zip":"94105"}} -#[tokio::test] -async fn test_ref_property_within_inline_schema_flattened_as_dot_notation_flags() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/orders")) - .and(header_regex("Authorization", "Bearer .+")) - .and(body_json(json!({ - "note": "rush", - "address": {"city": "SF", "zip": "94105"} - }))) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({"id": "order-1"})), - ) - .expect(1) - .mount(&server) - .await; - - let output = live_cmd(&server) - .args([ - "orders", - "create", - "--note", - "rush", - "--address.city", - "SF", - "--address.zip", - "94105", - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("order-1")); -} - -/// Tier-2: --json overrides per-field flags when keys overlap on a nested-body endpoint. -/// --name.first flag value is overridden by --json that provides a different name object. -#[tokio::test] -async fn test_json_overrides_nested_body_flags() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/persons")) - .and(header_regex("Authorization", "Bearer .+")) - .and(body_json(json!({ - "name": {"first": "George", "last": "Washington"}, - "role": "general" - }))) - .respond_with( - ResponseTemplate::new(201).set_body_json(json!({"id": "person-2"})), - ) - .expect(1) - .mount(&server) - .await; - - // --name.first "Abraham" is overridden by --json which provides the whole name object - let output = live_cmd(&server) - .args([ - "persons", - "create", - "--name.first", - "Abraham", - "--role", - "general", - "--json", - r#"{"name":{"first":"George","last":"Washington"}}"#, - ]) - .output() - .unwrap(); - - assert!( - output.status.success(), - "stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("person-2")); -} diff --git a/generators/cli/sdk/tests/openapi_streaming_wire.rs b/generators/cli/sdk/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/generators/cli/sdk/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/generators/cli/sdk/tests/tls_env_vars.rs b/generators/cli/sdk/tests/tls_env_vars.rs index fe2167e347e2..a5de7ede19e5 100644 --- a/generators/cli/sdk/tests/tls_env_vars.rs +++ b/generators/cli/sdk/tests/tls_env_vars.rs @@ -1,14 +1,14 @@ //! Integration test for the SDK's TLS env var contract. //! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. +//! Verifies that `BIGCOMMERCE_CA_BUNDLE`, `BIGCOMMERCE_INSECURE`, +//! `SSL_CERT_FILE`, etc. actually change the TLS trust outcome of the +//! HTTP client built by [`fern_cli_sdk::http::HttpConfig::build_client`]. //! //! Approach: spin up a local HTTPS server with a brand-new self-signed cert //! that is never trusted by the system, then exercise the client against it //! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). +//! whatever's in the developer's keychain (the reason live tests against +//! BigCommerce can't be trusted to verify env-var behavior in isolation). //! //! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI //! machines). The test will skip itself with a printed warning if either is diff --git a/generators/cli/sdk/tests/websocket_wire.rs b/generators/cli/sdk/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/generators/cli/sdk/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/generators/cli/sdk/tests/x_name_server_alias_wire.rs b/generators/cli/sdk/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/generators/cli/sdk/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/generators/cli/src/__test__/copySpecs.test.ts b/generators/cli/src/__test__/copySpecs.test.ts index c261d87f6c1a..65b375d620c5 100644 --- a/generators/cli/src/__test__/copySpecs.test.ts +++ b/generators/cli/src/__test__/copySpecs.test.ts @@ -94,7 +94,7 @@ describe("copySpecs", () => { await expect(access(path.join(outputDir, BIN_DIR))).rejects.toThrow(); }); - it("single openapi spec, no auth bindings → writes spec + emits single-.spec() main.rs", async () => { + it("single openapi spec, no auth bindings → writes spec + emits OpenApiBinding main.rs", async () => { const specsDir = path.join(tmpDir, "specs"); await mkdir(specsDir, { recursive: true }); await writeFile(path.join(specsDir, "openapi0.json"), '{"openapi":"3.0.0","info":{"title":"users"}}'); @@ -114,12 +114,11 @@ describe("copySpecs", () => { ); const main = await readFile(path.join(outputDir, BIN_DIR, "main.rs"), "utf-8"); expect(main).toContain(`CliApp::new("${BIN}")`); + expect(main).toContain("OpenApiBinding::new()"); expect(main).toContain('.spec(include_str!("openapi0.json"))'); - // No bindings supplied → no auth calls + no AuthCredentialSource import. - expect(main).not.toContain(".auth_scheme_env"); - expect(main).not.toContain(".auth_basic_scheme"); - expect(main).not.toContain("AuthCredentialSource"); expect(main).not.toContain(".spec_under"); + expect(main).toContain("use fern_cli_sdk::app::CliApp;"); + expect(main).toContain("use fern_cli_sdk::openapi::OpenApiBinding;"); }); it("multi-spec without namespace → emits .spec(...) per entry so they merge flat at the root", async () => { @@ -202,7 +201,7 @@ describe("copySpecs", () => { expect(main).toContain('.spec_under("admin", include_str!("openapi1.json"))'); }); - it("threads supplied auth bindings into main.rs, in supplied order", async () => { + it("threads root auth bindings above binding and binding-level auth into OpenApiBinding", async () => { const specsDir = path.join(tmpDir, "specs"); await mkdir(specsDir, { recursive: true }); await writeFile(path.join(specsDir, "openapi0.json"), '{"openapi":"3.0.0"}'); @@ -219,36 +218,36 @@ describe("copySpecs", () => { { schemeName: "ApiKeyAuth", rustCall: - '.auth_basic_scheme("ApiKeyAuth", ' + - 'AuthCredentialSource::from_env("CLOSE_API_KEY"), ' + - 'AuthCredentialSource::literal(""))', - needsCredentialSourceImport: true + '.auth_basic_scheme_username_only("ApiKeyAuth", ' + + 'AuthCredentialSource::from_env("CLOSE_API_KEY"))', + placement: "binding", + authTypeImport: "AuthCredentialSource" }, { schemeName: "OAuth2", - rustCall: '.auth_scheme_env("OAuth2", "CLOSE_TOKEN")', - needsCredentialSourceImport: false + rustCall: '.auth(BearerAuth::new("OAuth2").env("CLOSE_TOKEN"))', + placement: "root", + authTypeImport: "BearerAuth" } ]; await copySpecs({ outputDir, binaryName: "close", authBindings: bindings, specsDir }); const main = await readFile(path.join(outputDir, "cli", "close", "main.rs"), "utf-8"); - // Import for AuthCredentialSource is emitted because at least one - // binding sets needsCredentialSourceImport, and it sits above the - // CliApp import for stable ordering. - const credImportIdx = main.indexOf("use fern_cli_sdk::auth::AuthCredentialSource;"); - const cliImportIdx = main.indexOf("use fern_cli_sdk::openapi::CliApp;"); - expect(credImportIdx).toBeGreaterThanOrEqual(0); - expect(cliImportIdx).toBeGreaterThan(credImportIdx); - - // Bindings appear inline in the builder chain in the order supplied. - const basicIdx = main.indexOf('.auth_basic_scheme("ApiKeyAuth"'); - const bearerIdx = main.indexOf('.auth_scheme_env("OAuth2", "CLOSE_TOKEN")'); - expect(basicIdx).toBeGreaterThan(0); - expect(bearerIdx).toBeGreaterThan(basicIdx); + // Auth type imports are emitted + expect(main).toContain("use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuth};"); + + // Root auth (BearerAuth) appears before the .binding() call + const rootAuthIdx = main.indexOf('.auth(BearerAuth::new("OAuth2")'); + const bindingIdx = main.indexOf(".binding("); + expect(rootAuthIdx).toBeGreaterThan(0); + expect(bindingIdx).toBeGreaterThan(rootAuthIdx); + + // Binding-level auth appears inside the OpenApiBinding chain + const basicIdx = main.indexOf('.auth_basic_scheme_username_only("ApiKeyAuth"'); + expect(basicIdx).toBeGreaterThan(bindingIdx); }); - it("only emits AuthCredentialSource import when at least one binding needs it", async () => { + it("only emits auth type imports when at least one binding uses them", async () => { const specsDir = path.join(tmpDir, "specs"); await mkdir(specsDir, { recursive: true }); await writeFile(path.join(specsDir, "openapi0.json"), '{"openapi":"3.0.0"}'); @@ -267,15 +266,17 @@ describe("copySpecs", () => { authBindings: [ { schemeName: "Bearer", - rustCall: '.auth_scheme_env("Bearer", "ACME_TOKEN")', - needsCredentialSourceImport: false + rustCall: '.auth(BearerAuth::new("Bearer").env("ACME_TOKEN"))', + placement: "root", + authTypeImport: "BearerAuth" } ], specsDir }); const main = await readFile(path.join(outputDir, BIN_DIR, "main.rs"), "utf-8"); - expect(main).toContain('.auth_scheme_env("Bearer", "ACME_TOKEN")'); + expect(main).toContain('.auth(BearerAuth::new("Bearer").env("ACME_TOKEN"))'); + expect(main).toContain("use fern_cli_sdk::auth::{BearerAuth};"); expect(main).not.toContain("AuthCredentialSource"); }); }); diff --git a/generators/cli/src/__test__/detectAuth.test.ts b/generators/cli/src/__test__/detectAuth.test.ts index 263ae94d6ce1..cb0eb2ea3992 100644 --- a/generators/cli/src/__test__/detectAuth.test.ts +++ b/generators/cli/src/__test__/detectAuth.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { detectAuthBindings } from "../detectAuth.js"; /** - * Coverage for the IR → `auth_scheme*` mapping. The IR SDK's + * Coverage for the IR → auth binding mapping. The IR SDK's * `AuthScheme` constructors install the `_visit` method `detectAuth` * relies on, so we always go through them — never hand-assemble raw * `{ type: "bearer", ... }` objects. @@ -66,8 +66,9 @@ describe("detectAuthBindings", () => { binaryName: "acme" }); expect(bindings).toHaveLength(1); - expect(bindings[0]?.rustCall).toBe('.auth_scheme_env("OAuth2", "ACME_OAUTH_TOKEN")'); - expect(bindings[0]?.needsCredentialSourceImport).toBe(false); + expect(bindings[0]?.rustCall).toBe('.auth(BearerAuth::new("OAuth2").env("ACME_OAUTH_TOKEN"))'); + expect(bindings[0]?.placement).toBe("root"); + expect(bindings[0]?.authTypeImport).toBe("BearerAuth"); }); it("bearer without tokenEnvVar falls back to _TOKEN (clean, no scheme noise)", () => { @@ -75,7 +76,7 @@ describe("detectAuthBindings", () => { auth: auth(bearer({ key: "OAuth2" })), binaryName: "close" }); - expect(bindings[0]?.rustCall).toBe('.auth_scheme_env("OAuth2", "CLOSE_TOKEN")'); + expect(bindings[0]?.rustCall).toBe('.auth(BearerAuth::new("OAuth2").env("CLOSE_TOKEN"))'); }); it("header scheme with headerEnvVar uses the IR value", () => { @@ -83,7 +84,9 @@ describe("detectAuthBindings", () => { auth: auth(header({ key: "ApiKey", headerEnvVar: "CLOSE_API_KEY" })), binaryName: "close" }); - expect(bindings[0]?.rustCall).toBe('.auth_scheme_env("ApiKey", "CLOSE_API_KEY")'); + expect(bindings[0]?.rustCall).toBe('.auth(ApiKeyAuth::new("ApiKey").env("CLOSE_API_KEY"))'); + expect(bindings[0]?.placement).toBe("root"); + expect(bindings[0]?.authTypeImport).toBe("ApiKeyAuth"); }); it("header scheme without headerEnvVar falls back to _API_KEY", () => { @@ -91,7 +94,7 @@ describe("detectAuthBindings", () => { auth: auth(header({ key: "ApiKey" })), binaryName: "close" }); - expect(bindings[0]?.rustCall).toBe('.auth_scheme_env("ApiKey", "CLOSE_API_KEY")'); + expect(bindings[0]?.rustCall).toBe('.auth(ApiKeyAuth::new("ApiKey").env("CLOSE_API_KEY"))'); }); it("basic auth: IR usernameEnvVar + passwordEnvVar drive both sources", () => { @@ -104,31 +107,29 @@ describe("detectAuthBindings", () => { 'AuthCredentialSource::from_env("CLOSE_USER"), ' + 'AuthCredentialSource::from_env("CLOSE_PASS"))' ); - expect(bindings[0]?.needsCredentialSourceImport).toBe(true); + expect(bindings[0]?.placement).toBe("binding"); + expect(bindings[0]?.authTypeImport).toBe("AuthCredentialSource"); }); - it("basic auth with passwordOmit (Close pattern): emits the SDK's username-only builder", () => { - // The username-only builder lowers to BasicAuthProvider::username_only, whose - // has_credentials() check only inspects the username — fixes the silent-drop - // bug from the earlier `auth_basic_scheme(..., literal(""))` shape, where the - // empty literal resolves to None and trips Full mode's "both must resolve" gate. + it("basic auth with passwordOmit (Close pattern): emits auth_provider with BasicAuthProvider::username_only", () => { const bindings = detectAuthBindings({ auth: auth(basic({ key: "ApiKeyAuth", usernameEnvVar: "CLOSE_API_KEY", passwordOmit: true })), binaryName: "close" }); expect(bindings[0]?.rustCall).toBe( - '.auth_basic_scheme_username_only("ApiKeyAuth", AuthCredentialSource::from_env("CLOSE_API_KEY"))' + '.auth_provider("ApiKeyAuth", BasicAuthProvider::username_only("ApiKeyAuth", AuthCredentialSource::from_env("CLOSE_API_KEY")))' ); - expect(bindings[0]?.needsCredentialSourceImport).toBe(true); + expect(bindings[0]?.placement).toBe("binding"); + expect(bindings[0]?.authTypeImport).toBe("AuthCredentialSource, BasicAuthProvider"); }); - it("basic auth with usernameOmit: emits the SDK's password-only builder", () => { + it("basic auth with usernameOmit: emits auth_provider with BasicAuthProvider::password_only", () => { const bindings = detectAuthBindings({ auth: auth(basic({ key: "BasicAuth", usernameOmit: true, passwordEnvVar: "ACME_PASS" })), binaryName: "acme" }); expect(bindings[0]?.rustCall).toBe( - '.auth_basic_scheme_password_only("BasicAuth", AuthCredentialSource::from_env("ACME_PASS"))' + '.auth_provider("BasicAuth", BasicAuthProvider::password_only("BasicAuth", AuthCredentialSource::from_env("ACME_PASS")))' ); }); @@ -161,8 +162,10 @@ describe("detectAuthBindings", () => { binaryName: "close" }); expect(bindings).toHaveLength(2); - expect(bindings[0]?.rustCall).toContain('.auth_basic_scheme_username_only("ApiKeyAuth"'); - expect(bindings[1]?.rustCall).toBe('.auth_scheme_env("OAuth2", "CLOSE_TOKEN")'); + expect(bindings[0]?.rustCall).toContain('.auth_provider("ApiKeyAuth", BasicAuthProvider::username_only('); + expect(bindings[0]?.placement).toBe("binding"); + expect(bindings[1]?.rustCall).toBe('.auth(BearerAuth::new("OAuth2").env("CLOSE_TOKEN"))'); + expect(bindings[1]?.placement).toBe("root"); }); // `oauth: () => null` and `inferred: () => null` branches are diff --git a/generators/cli/src/__test__/runPipeline.test.ts b/generators/cli/src/__test__/runPipeline.test.ts index 693e5922d7c0..01f64f2ee6b1 100644 --- a/generators/cli/src/__test__/runPipeline.test.ts +++ b/generators/cli/src/__test__/runPipeline.test.ts @@ -194,10 +194,10 @@ describe("runPipeline", () => { expect(outcome).toEqual({ status: "generated", binaryName: "close" }); const main = await readFile(path.join(outputDir, "cli", "close", "main.rs"), "utf-8"); expect(main).toContain( - '.auth_basic_scheme_username_only("ApiKeyAuth", AuthCredentialSource::from_env("CLOSE_API_KEY"))' + '.auth_provider("ApiKeyAuth", BasicAuthProvider::username_only("ApiKeyAuth", AuthCredentialSource::from_env("CLOSE_API_KEY")))' ); - expect(main).toContain('.auth_scheme_env("OAuth2", "CLOSE_TOKEN")'); - expect(main).toContain("use fern_cli_sdk::auth::AuthCredentialSource;"); + expect(main).toContain('.auth(BearerAuth::new("OAuth2").env("CLOSE_TOKEN"))'); + expect(main).toContain("use fern_cli_sdk::auth::{AuthCredentialSource, BasicAuthProvider, BearerAuth};"); }); it("no customConfig.binaryName + no IR apiDisplayName surfaces a clear error before any disk write", async () => { diff --git a/generators/cli/src/copySpecs.ts b/generators/cli/src/copySpecs.ts index a63e253cca52..cd596ed780d3 100644 --- a/generators/cli/src/copySpecs.ts +++ b/generators/cli/src/copySpecs.ts @@ -96,29 +96,57 @@ interface SpecEntry { function renderMainRs(args: { binaryName: string; entries: SpecEntry[]; authBindings: DetectedAuthBinding[] }): string { const { binaryName, entries, authBindings } = args; - const needsCredentialSourceImport = authBindings.some((b) => b.needsCredentialSourceImport); + + // Separate root-level auth (typed builders) from binding-level auth + const rootAuthBindings = authBindings.filter((b) => b.placement === "root"); + const bindingAuthBindings = authBindings.filter((b) => b.placement === "binding"); + + // Collect needed imports + const imports: string[] = ["use fern_cli_sdk::app::CliApp;", "use fern_cli_sdk::openapi::OpenApiBinding;"]; + const authTypeImports = new Set(); + for (const binding of [...rootAuthBindings, ...bindingAuthBindings]) { + if (binding.authTypeImport != null) { + for (const imp of binding.authTypeImport.split(",")) { + authTypeImports.add(imp.trim()); + } + } + } + if (authTypeImports.size > 0) { + imports.push(`use fern_cli_sdk::auth::{${[...authTypeImports].sort().join(", ")}};`); + } const lines: string[] = [ "// Auto-generated by @fern-api/cli-generator's copySpecs step.", "// Edit the SDK template / generator if you need to change the shape.", - "" + "", + ...imports, + "", + "fn main() {", + ` CliApp::new("${binaryName}")` ]; - if (needsCredentialSourceImport) { - lines.push("use fern_cli_sdk::auth::AuthCredentialSource;"); + + // Root-level auth bindings (typed builders) + for (const binding of rootAuthBindings) { + lines.push(` ${binding.rustCall}`); } - lines.push("use fern_cli_sdk::openapi::CliApp;", "", "fn main() {", ` CliApp::new("${binaryName}")`); + // OpenApiBinding with specs and binding-level auth + lines.push(" .binding("); + lines.push(" OpenApiBinding::new()"); for (const entry of entries) { const include = `include_str!("${entry.destFilename}")`; if (entry.namespace != null && entry.namespace !== "") { - lines.push(` .spec_under("${entry.namespace}", ${include})`); + lines.push(` .spec_under("${entry.namespace}", ${include})`); } else { - lines.push(` .spec(${include})`); + lines.push(` .spec(${include})`); } } - for (const binding of authBindings) { - lines.push(` ${binding.rustCall}`); + for (const binding of bindingAuthBindings) { + lines.push(` ${binding.rustCall}`); } + // Close the binding — use trailing comma for clean formatting + lines.push(" )"); + lines.push(" .run()"); lines.push("}"); lines.push(""); diff --git a/generators/cli/src/detectAuth.ts b/generators/cli/src/detectAuth.ts index a444d23fa81c..ad92f8977eba 100644 --- a/generators/cli/src/detectAuth.ts +++ b/generators/cli/src/detectAuth.ts @@ -3,45 +3,39 @@ import { toEnvVarPrefix } from "./identity.js"; /** * One auth binding to emit in the generated `main.rs`. The `rustCall` - * string is the literal method-chain fragment (e.g. - * `.auth_scheme_env("bearerAuth", "ACME_TOKEN")`); the rendering layer - * just splices these into the `CliApp::new(...)` builder. + * string is the literal method-chain fragment; the rendering layer + * splices these into the `CliApp::new(...)` builder at either root level + * (typed builders like `BearerAuth`) or binding level (on `OpenApiBinding`). */ export interface DetectedAuthBinding { /** Scheme name as declared in `generators.yml`'s `auth-schemes` (the IR's `key`). */ schemeName: string; /** Literal Rust method-chain call, minus the leading whitespace. */ rustCall: string; - /** Whether this binding needs `use fern_cli_sdk::auth::AuthCredentialSource;` at the top of main.rs. */ - needsCredentialSourceImport: boolean; + /** Where this auth binding should be placed in the generated main.rs. */ + placement: "root" | "binding"; + /** Rust type to import from `fern_cli_sdk::auth`, if any. */ + authTypeImport: string | null; } /** * Visit each scheme in the IR's `auth.schemes` and emit a binding - * for the variants the SDK's `auth_scheme*` API supports: + * for the variants the SDK supports: * - * - `bearer` → `.auth_scheme_env("", "_TOKEN")` - * - `header` → `.auth_scheme_env("", "_API_KEY")` + * - `bearer` → `.auth(BearerAuth::new("").env(""))` + * - `header` → `.auth(ApiKeyAuth::new("").env(""))` * - `basic` (both halves bound) → `.auth_basic_scheme(...)` * - `basic` with `passwordOmit: true` → - * `.auth_basic_scheme_username_only("", from_env())` - * — the Close API's "API key in basic-auth username slot" pattern. - * Goes through the SDK's dedicated username-only path because the - * equivalent `auth_basic_scheme(..., literal(""))` would silently - * drop the binding: `Literal("")` resolves to `None`, which trips - * `BasicAuthMode::Full`'s "both must resolve" check. + * `.auth_provider("", BasicAuthProvider::username_only(...))` * - `basic` with `usernameOmit: true` → symmetric - * `.auth_basic_scheme_password_only(...)` + * `.auth_provider("", BasicAuthProvider::password_only(...))` * - `basic` with both omitted → skipped (nothing to bind) * - `oauth` / `inferred` / unknown → skipped (the SDK currently has no - * runtime provider for these; visiting them via `_other` keeps the - * union forward-compatible) + * runtime provider for these) * * Env-var names come from the IR first (`usernameEnvVar`, - * `passwordEnvVar`, `tokenEnvVar`, `headerEnvVar` — these resolve from - * `generators.yml`'s `auth-schemes`). When the IR doesn't pin one, we - * fall back to `_` so the customer gets a clean, predictable - * name (`CLOSE_TOKEN`) rather than a noisy `__`. + * `passwordEnvVar`, `tokenEnvVar`, `headerEnvVar`). When the IR doesn't + * pin one, we fall back to `_`. */ export function detectAuthBindings(args: { auth: { schemes: FernIr.AuthScheme[] }; @@ -66,16 +60,18 @@ function bindingForScheme(scheme: FernIr.AuthScheme, envPrefix: string): Detecte const env = bearer.tokenEnvVar ?? `${envPrefix}_TOKEN`; return { schemeName: bearer.key, - rustCall: `.auth_scheme_env("${bearer.key}", "${env}")`, - needsCredentialSourceImport: false + rustCall: `.auth(BearerAuth::new("${bearer.key}").env("${env}"))`, + placement: "root", + authTypeImport: "BearerAuth" }; }, header: (header) => { const env = header.headerEnvVar ?? `${envPrefix}_API_KEY`; return { schemeName: header.key, - rustCall: `.auth_scheme_env("${header.key}", "${env}")`, - needsCredentialSourceImport: false + rustCall: `.auth(ApiKeyAuth::new("${header.key}").env("${env}"))`, + placement: "root", + authTypeImport: "ApiKeyAuth" }; }, basic: (basic) => { @@ -86,29 +82,28 @@ function bindingForScheme(scheme: FernIr.AuthScheme, envPrefix: string): Detecte if (basic.usernameOmit && basic.passwordOmit) { return null; } - // password omitted → API key in the username slot. Use the - // SDK's specialised builder so `has_credentials()` checks - // only the username; the equivalent - // `auth_basic_scheme(..., literal(""))` would resolve to - // `None` and drop the binding silently. + // password omitted → API key in the username slot. if (basic.passwordOmit) { return { schemeName: basic.key, - rustCall: `.auth_basic_scheme_username_only("${basic.key}", AuthCredentialSource::from_env("${usernameEnv}"))`, - needsCredentialSourceImport: true + rustCall: `.auth_provider("${basic.key}", BasicAuthProvider::username_only("${basic.key}", AuthCredentialSource::from_env("${usernameEnv}")))`, + placement: "binding", + authTypeImport: "AuthCredentialSource, BasicAuthProvider" }; } if (basic.usernameOmit) { return { schemeName: basic.key, - rustCall: `.auth_basic_scheme_password_only("${basic.key}", AuthCredentialSource::from_env("${passwordEnv}"))`, - needsCredentialSourceImport: true + rustCall: `.auth_provider("${basic.key}", BasicAuthProvider::password_only("${basic.key}", AuthCredentialSource::from_env("${passwordEnv}")))`, + placement: "binding", + authTypeImport: "AuthCredentialSource, BasicAuthProvider" }; } return { schemeName: basic.key, rustCall: `.auth_basic_scheme("${basic.key}", AuthCredentialSource::from_env("${usernameEnv}"), AuthCredentialSource::from_env("${passwordEnv}"))`, - needsCredentialSourceImport: true + placement: "binding", + authTypeImport: "AuthCredentialSource" }; }, // The SDK doesn't yet have a runtime provider for OAuth client diff --git a/packages/cli/api-importers/graphql/package.json b/packages/cli/api-importers/graphql/package.json index 88cb950399d2..c8513a8fbd99 100644 --- a/packages/cli/api-importers/graphql/package.json +++ b/packages/cli/api-importers/graphql/package.json @@ -31,7 +31,7 @@ "test:update": "vitest --run -u --passWithNoTests" }, "dependencies": { - "@fern-api/fdr-sdk": "1.2.4-f661387fb2", + "@fern-api/fdr-sdk": "1.2.16-3b754a56be", "@fern-api/fs-utils": "workspace:*", "@fern-api/task-context": "workspace:*", "graphql": "catalog:" diff --git a/packages/cli/auth/src/orgs/checkOrganizationMembership.ts b/packages/cli/auth/src/orgs/checkOrganizationMembership.ts index 67308d30bf20..2ad22a225175 100644 --- a/packages/cli/auth/src/orgs/checkOrganizationMembership.ts +++ b/packages/cli/auth/src/orgs/checkOrganizationMembership.ts @@ -17,14 +17,14 @@ export async function checkOrganizationMembership({ const venus = createVenusService({ token: token.value }); // First check if the user is a member of the organization. - const isMemberResponse = await venus.organization.isMember({ organizationId: organization }); + const isMemberResponse = await venus.organization.isMember(organization); if (isMemberResponse.ok && isMemberResponse.body) { return { type: "member" }; } // Either the isMember call failed or the user is not a member. // Check whether the org exists at all. - const getResponse = await venus.organization.get({ orgId: organization }); + const getResponse = await venus.organization.get(organization); if (getResponse.ok) { // Org exists but user is not a member. return { type: "no-access" }; @@ -33,8 +33,8 @@ export async function checkOrganizationMembership({ // Org doesn't exist (or we got an auth error checking it). let result: OrganizationCheckResult = { type: "unknown-error" }; getResponse.error._visit({ - unprocessableEntityError: () => { - result = { type: "not-found" }; + unauthorizedError: () => { + result = { type: "no-access" }; }, _other: () => { // TODO: We should actually send a 404 to more clearly communicate a not-found error. diff --git a/packages/cli/auth/src/orgs/createOrganizationIfDoesNotExist.ts b/packages/cli/auth/src/orgs/createOrganizationIfDoesNotExist.ts index 8c23d1380190..43458567a673 100644 --- a/packages/cli/auth/src/orgs/createOrganizationIfDoesNotExist.ts +++ b/packages/cli/auth/src/orgs/createOrganizationIfDoesNotExist.ts @@ -13,7 +13,7 @@ export async function createOrganizationIfDoesNotExist({ context: TaskContext; }): Promise { const venus = createVenusService({ token: token.value }); - const getOrganizationResponse = await venus.organization.get({ orgId: organization }); + const getOrganizationResponse = await venus.organization.get(organization); if (getOrganizationResponse.ok) { return false; diff --git a/packages/cli/cli-v2/src/commands/auth/token/command.ts b/packages/cli/cli-v2/src/commands/auth/token/command.ts index 8c4c1f26727a..36f619fefb1d 100644 --- a/packages/cli/cli-v2/src/commands/auth/token/command.ts +++ b/packages/cli/cli-v2/src/commands/auth/token/command.ts @@ -39,12 +39,26 @@ export class TokenCommand { } response.error._visit({ - unprocessableEntityError: () => { + organizationNotFoundError: () => { process.stderr.write(`${Icons.error} Organization "${orgId}" was not found.\n`); throw new CliError({ code: CliError.Code.ConfigError }); }, + unauthorizedError: () => { + process.stderr.write(`${Icons.error} You do not have access to organization "${orgId}".\n`); + throw new CliError({ + code: CliError.Code.AuthError + }); + }, + missingOrgPermissionsError: () => { + process.stderr.write( + `${Icons.error} You do not have the required permissions in organization "${orgId}".\n` + ); + throw new CliError({ + code: CliError.Code.AuthError + }); + }, _other: () => { process.stderr.write( `${Icons.error} Failed to generate token.\n` + diff --git a/packages/cli/cli-v2/src/commands/org/member/add/__test__/command.test.ts b/packages/cli/cli-v2/src/commands/org/member/add/__test__/command.test.ts index b642484ae9d4..7cebf1ce7ab9 100644 --- a/packages/cli/cli-v2/src/commands/org/member/add/__test__/command.test.ts +++ b/packages/cli/cli-v2/src/commands/org/member/add/__test__/command.test.ts @@ -58,7 +58,7 @@ describe("InviteMemberCommand", () => { const context = createMockContext(); await cmd.handle(context, { email: "user@example.com", org: "acme" } as InviteMemberCommand.Args); - expect(mockGet).toHaveBeenCalledWith({ orgId: "acme" }); + expect(mockGet).toHaveBeenCalledWith("acme"); expect(mockInviteUser).toHaveBeenCalledWith({ emailAddress: "user@example.com", auth0OrgId: "org_abc123" @@ -104,7 +104,7 @@ describe("InviteMemberCommand", () => { const mockGet = vi.fn().mockResolvedValue({ ok: false, error: { - _visit: (visitor: { unprocessableEntityError: () => void }) => visitor.unprocessableEntityError() + _visit: (visitor: { unauthorizedError: () => void }) => visitor.unauthorizedError() } }); vi.mocked(createVenusService).mockReturnValue({ @@ -116,16 +116,18 @@ describe("InviteMemberCommand", () => { cmd.handle(context, { email: "user@example.com", org: "acme" } as InviteMemberCommand.Args) ).rejects.toThrow(CliError); - expect(context.stderr.error).toHaveBeenCalledWith(expect.stringContaining("was not found")); + expect(context.stderr.error).toHaveBeenCalledWith( + expect.stringContaining("do not have access to organization") + ); }); - it("should handle UnprocessableEntityError from inviteUser", async () => { + it("should handle UnauthorizedError from inviteUser", async () => { const { createVenusService } = await import("@fern-api/core"); const mockGet = mockOrgLookupSuccess(); const mockInviteUser = vi.fn().mockResolvedValue({ ok: false, error: { - _visit: (visitor: { unprocessableEntityError: () => void }) => visitor.unprocessableEntityError() + _visit: (visitor: { unauthorizedError: () => void }) => visitor.unauthorizedError() } }); vi.mocked(createVenusService).mockReturnValue({ @@ -137,16 +139,16 @@ describe("InviteMemberCommand", () => { cmd.handle(context, { email: "user@example.com", org: "acme" } as InviteMemberCommand.Args) ).rejects.toThrow(CliError); - expect(context.stderr.error).toHaveBeenCalledWith(expect.stringContaining("No user found with email")); + expect(context.stderr.error).toHaveBeenCalledWith(expect.stringContaining("do not have permission to invite")); }); - it("should handle unknown error from inviteUser", async () => { + it("should handle UserIdDoesNotExistError", async () => { const { createVenusService } = await import("@fern-api/core"); const mockGet = mockOrgLookupSuccess(); const mockInviteUser = vi.fn().mockResolvedValue({ ok: false, error: { - _visit: (visitor: { _other: () => void }) => visitor._other() + _visit: (visitor: { userIdDoesNotExistError: () => void }) => visitor.userIdDoesNotExistError() } }); vi.mocked(createVenusService).mockReturnValue({ @@ -158,7 +160,7 @@ describe("InviteMemberCommand", () => { cmd.handle(context, { email: "user@example.com", org: "acme" } as InviteMemberCommand.Args) ).rejects.toThrow(CliError); - expect(context.stderr.error).toHaveBeenCalledWith(expect.stringContaining("Failed to invite member")); + expect(context.stderr.error).toHaveBeenCalledWith(expect.stringContaining("No user found with email")); }); it("should handle unknown errors", async () => { diff --git a/packages/cli/cli-v2/src/commands/org/member/add/command.ts b/packages/cli/cli-v2/src/commands/org/member/add/command.ts index 462a9a463fc5..7c51772610c2 100644 --- a/packages/cli/cli-v2/src/commands/org/member/add/command.ts +++ b/packages/cli/cli-v2/src/commands/org/member/add/command.ts @@ -28,15 +28,15 @@ export class InviteMemberCommand { const venus = createVenusService({ token: token.value }); - const orgLookup = await venus.organization.get({ orgId: args.org }); + const orgLookup = await venus.organization.get(args.org); if (!orgLookup.ok) { orgLookup.error._visit({ - unprocessableEntityError: () => { - context.stderr.error(`${Icons.error} Organization "${args.org}" was not found.`); - throw CliError.notFound(); + unauthorizedError: () => { + context.stderr.error(`${Icons.error} You do not have access to organization "${args.org}".`); + throw new CliError({ code: CliError.Code.AuthError }); }, _other: () => { - context.stderr.error(`${Icons.error} Failed to look up organization "${args.org}".`); + context.stderr.error(`${Icons.error} Organization "${args.org}" was not found.`); throw CliError.notFound(); } }); @@ -63,7 +63,13 @@ export class InviteMemberCommand { } response.error._visit({ - unprocessableEntityError: () => { + unauthorizedError: () => { + context.stderr.error( + `${Icons.error} You do not have permission to invite members to organization "${args.org}".` + ); + throw new CliError({ code: CliError.Code.AuthError }); + }, + userIdDoesNotExistError: () => { context.stderr.error(`${Icons.error} No user found with email "${args.email}".`); throw CliError.notFound(); }, diff --git a/packages/cli/cli-v2/src/commands/org/member/list/__test__/command.test.ts b/packages/cli/cli-v2/src/commands/org/member/list/__test__/command.test.ts index 4d9e536f914f..844e41754571 100644 --- a/packages/cli/cli-v2/src/commands/org/member/list/__test__/command.test.ts +++ b/packages/cli/cli-v2/src/commands/org/member/list/__test__/command.test.ts @@ -58,7 +58,7 @@ describe("ListMembersCommand", () => { const context = createMockContext(); await cmd.handle(context, { org: "acme" } as ListMembersCommand.Args); - expect(mockGet).toHaveBeenCalledWith({ orgId: "acme" }); + expect(mockGet).toHaveBeenCalledWith("acme"); expect(context.stdout.info).toHaveBeenCalledTimes(2); }); @@ -118,12 +118,12 @@ describe("ListMembersCommand", () => { ); }); - it("should handle UnprocessableEntityError", async () => { + it("should handle UnauthorizedError", async () => { const { createVenusService } = await import("@fern-api/core"); const mockGet = vi.fn().mockResolvedValue({ ok: false, error: { - _visit: (visitor: { unprocessableEntityError: () => void }) => visitor.unprocessableEntityError() + _visit: (visitor: { unauthorizedError: () => void }) => visitor.unauthorizedError() } }); vi.mocked(createVenusService).mockReturnValue({ @@ -133,7 +133,9 @@ describe("ListMembersCommand", () => { const context = createMockContext(); await expect(cmd.handle(context, { org: "acme" } as ListMembersCommand.Args)).rejects.toThrow(CliError); - expect(context.stderr.error).toHaveBeenCalledWith(expect.stringContaining("was not found")); + expect(context.stderr.error).toHaveBeenCalledWith( + expect.stringContaining("do not have access to organization") + ); }); it("should handle unknown errors", async () => { diff --git a/packages/cli/cli-v2/src/commands/org/member/list/command.ts b/packages/cli/cli-v2/src/commands/org/member/list/command.ts index a0393270cd21..93ba9e52da82 100644 --- a/packages/cli/cli-v2/src/commands/org/member/list/command.ts +++ b/packages/cli/cli-v2/src/commands/org/member/list/command.ts @@ -29,14 +29,14 @@ export class ListMembersCommand { const response = await withSpinner({ message: `Fetching members of organization "${args.org}"`, - operation: () => venus.organization.get({ orgId: args.org }) + operation: () => venus.organization.get(args.org) }); if (!response.ok) { response.error._visit({ - unprocessableEntityError: () => { - context.stderr.error(`${Icons.error} Organization "${args.org}" was not found.`); - throw CliError.notFound(); + unauthorizedError: () => { + context.stderr.error(`${Icons.error} You do not have access to organization "${args.org}".`); + throw new CliError({ code: CliError.Code.AuthError }); }, _other: () => { context.stderr.error( diff --git a/packages/cli/cli-v2/src/commands/org/member/remove/__test__/command.test.ts b/packages/cli/cli-v2/src/commands/org/member/remove/__test__/command.test.ts index f06c0101dc7d..de5898010aca 100644 --- a/packages/cli/cli-v2/src/commands/org/member/remove/__test__/command.test.ts +++ b/packages/cli/cli-v2/src/commands/org/member/remove/__test__/command.test.ts @@ -58,7 +58,7 @@ describe("RemoveMemberCommand", () => { const context = createMockContext(); await cmd.handle(context, { userId: "user123", org: "acme" } as RemoveMemberCommand.Args); - expect(mockGet).toHaveBeenCalledWith({ orgId: "acme" }); + expect(mockGet).toHaveBeenCalledWith("acme"); expect(mockRemoveUser).toHaveBeenCalledWith({ userId: "user123", auth0OrgId: "org_abc123" @@ -112,16 +112,16 @@ describe("RemoveMemberCommand", () => { cmd.handle(context, { userId: "user123", org: "acme" } as RemoveMemberCommand.Args) ).rejects.toThrow(CliError); - expect(context.stderr.error).toHaveBeenCalledWith(expect.stringContaining("Failed to look up organization")); + expect(context.stderr.error).toHaveBeenCalledWith(expect.stringContaining("was not found")); }); - it("should handle UnprocessableEntityError from removeUser", async () => { + it("should handle UnauthorizedError from removeUser", async () => { const { createVenusService } = await import("@fern-api/core"); const mockGet = mockOrgLookupSuccess(); const mockRemoveUser = vi.fn().mockResolvedValue({ ok: false, error: { - _visit: (visitor: { unprocessableEntityError: () => void }) => visitor.unprocessableEntityError() + _visit: (visitor: { unauthorizedError: () => void }) => visitor.unauthorizedError() } }); vi.mocked(createVenusService).mockReturnValue({ @@ -133,16 +133,18 @@ describe("RemoveMemberCommand", () => { cmd.handle(context, { userId: "user123", org: "acme" } as RemoveMemberCommand.Args) ).rejects.toThrow(CliError); - expect(context.stderr.error).toHaveBeenCalledWith(expect.stringContaining("was not found")); + expect(context.stderr.error).toHaveBeenCalledWith( + expect.stringContaining("do not have permission to remove members") + ); }); - it("should handle unknown error from removeUser", async () => { + it("should handle UserIdDoesNotExistError", async () => { const { createVenusService } = await import("@fern-api/core"); const mockGet = mockOrgLookupSuccess(); const mockRemoveUser = vi.fn().mockResolvedValue({ ok: false, error: { - _visit: (visitor: { _other: () => void }) => visitor._other() + _visit: (visitor: { userIdDoesNotExistError: () => void }) => visitor.userIdDoesNotExistError() } }); vi.mocked(createVenusService).mockReturnValue({ @@ -154,7 +156,7 @@ describe("RemoveMemberCommand", () => { cmd.handle(context, { userId: "user123", org: "acme" } as RemoveMemberCommand.Args) ).rejects.toThrow(CliError); - expect(context.stderr.error).toHaveBeenCalledWith(expect.stringContaining("Failed to remove member")); + expect(context.stderr.error).toHaveBeenCalledWith(expect.stringContaining("was not found")); }); it("should handle unknown errors", async () => { diff --git a/packages/cli/cli-v2/src/commands/org/member/remove/command.ts b/packages/cli/cli-v2/src/commands/org/member/remove/command.ts index 10ef573c3368..ed4af1d739b5 100644 --- a/packages/cli/cli-v2/src/commands/org/member/remove/command.ts +++ b/packages/cli/cli-v2/src/commands/org/member/remove/command.ts @@ -28,15 +28,15 @@ export class RemoveMemberCommand { const venus = createVenusService({ token: token.value }); - const orgLookup = await venus.organization.get({ orgId: args.org }); + const orgLookup = await venus.organization.get(args.org); if (!orgLookup.ok) { orgLookup.error._visit({ - unprocessableEntityError: () => { - context.stderr.error(`${Icons.error} Organization "${args.org}" was not found.`); - throw CliError.notFound(); + unauthorizedError: () => { + context.stderr.error(`${Icons.error} You do not have access to organization "${args.org}".`); + throw new CliError({ code: CliError.Code.AuthError }); }, _other: () => { - context.stderr.error(`${Icons.error} Failed to look up organization "${args.org}".`); + context.stderr.error(`${Icons.error} Organization "${args.org}" was not found.`); throw CliError.notFound(); } }); @@ -64,7 +64,13 @@ export class RemoveMemberCommand { } response.error._visit({ - unprocessableEntityError: () => { + unauthorizedError: () => { + context.stderr.error( + `${Icons.error} You do not have permission to remove members from organization "${args.org}".` + ); + throw new CliError({ code: CliError.Code.AuthError }); + }, + userIdDoesNotExistError: () => { context.stderr.error(`${Icons.error} User "${userId}" was not found.`); throw CliError.notFound(); }, diff --git a/packages/cli/cli-v2/src/commands/org/token/create/__test__/command.test.ts b/packages/cli/cli-v2/src/commands/org/token/create/__test__/command.test.ts index 9d0161e92fb3..60017c4f7596 100644 --- a/packages/cli/cli-v2/src/commands/org/token/create/__test__/command.test.ts +++ b/packages/cli/cli-v2/src/commands/org/token/create/__test__/command.test.ts @@ -62,7 +62,7 @@ describe("CreateTokenCommand", () => { const context = createMockContext(); await cmd.handle(context, { org: "acme", description: "CI token" } as CreateTokenCommand.Args); - expect(mockGet).toHaveBeenCalledWith({ orgId: "acme" }); + expect(mockGet).toHaveBeenCalledWith("acme"); expect(mockCreate).toHaveBeenCalledWith({ organizationId: "org_abc123", description: "CI token" @@ -118,16 +118,38 @@ describe("CreateTokenCommand", () => { const context = createMockContext(); await expect(cmd.handle(context, { org: "acme" } as CreateTokenCommand.Args)).rejects.toThrow(CliError); - expect(context.stderr.error).toHaveBeenCalledWith(expect.stringContaining("Failed to look up organization")); + expect(context.stderr.error).toHaveBeenCalledWith(expect.stringContaining("was not found")); + }); + + it("should handle UnauthorizedError from apiKeys.create", async () => { + const { createVenusService } = await import("@fern-api/core"); + const mockGet = mockOrgLookupSuccess(); + const mockCreate = vi.fn().mockResolvedValue({ + ok: false, + error: { + _visit: (visitor: { unauthorizedError: () => void }) => visitor.unauthorizedError() + } + }); + vi.mocked(createVenusService).mockReturnValue({ + organization: { get: mockGet }, + apiKeys: { create: mockCreate } + } as unknown as ReturnType); + + const context = createMockContext(); + await expect(cmd.handle(context, { org: "acme" } as CreateTokenCommand.Args)).rejects.toThrow(CliError); + + expect(context.stderr.error).toHaveBeenCalledWith( + expect.stringContaining("do not have access to organization") + ); }); - it("should handle UnprocessableEntityError from apiKeys.create", async () => { + it("should handle OrganizationNotFoundError", async () => { const { createVenusService } = await import("@fern-api/core"); const mockGet = mockOrgLookupSuccess(); const mockCreate = vi.fn().mockResolvedValue({ ok: false, error: { - _visit: (visitor: { unprocessableEntityError: () => void }) => visitor.unprocessableEntityError() + _visit: (visitor: { organizationNotFoundError: () => void }) => visitor.organizationNotFoundError() } }); vi.mocked(createVenusService).mockReturnValue({ diff --git a/packages/cli/cli-v2/src/commands/org/token/create/command.ts b/packages/cli/cli-v2/src/commands/org/token/create/command.ts index 5fb30920df3e..330c630124e4 100644 --- a/packages/cli/cli-v2/src/commands/org/token/create/command.ts +++ b/packages/cli/cli-v2/src/commands/org/token/create/command.ts @@ -28,15 +28,15 @@ export class CreateTokenCommand { const venus = createVenusService({ token: token.value }); - const orgLookup = await venus.organization.get({ orgId: args.org }); + const orgLookup = await venus.organization.get(args.org); if (!orgLookup.ok) { orgLookup.error._visit({ - unprocessableEntityError: () => { - context.stderr.error(`${Icons.error} Organization "${args.org}" was not found.`); - throw CliError.notFound(); + unauthorizedError: () => { + context.stderr.error(`${Icons.error} You do not have access to organization "${args.org}".`); + throw new CliError({ code: CliError.Code.AuthError }); }, _other: () => { - context.stderr.error(`${Icons.error} Failed to look up organization "${args.org}".`); + context.stderr.error(`${Icons.error} Organization "${args.org}" was not found.`); throw CliError.notFound(); } }); @@ -70,7 +70,11 @@ export class CreateTokenCommand { } response.error._visit({ - unprocessableEntityError: () => { + unauthorizedError: () => { + context.stderr.error(`${Icons.error} You do not have access to organization "${args.org}".`); + throw new CliError({ code: CliError.Code.AuthError }); + }, + organizationNotFoundError: () => { context.stderr.error(`${Icons.error} Organization "${args.org}" was not found.`); throw CliError.notFound(); }, diff --git a/packages/cli/cli-v2/src/commands/org/token/list/__test__/command.test.ts b/packages/cli/cli-v2/src/commands/org/token/list/__test__/command.test.ts index 0c6e7e1661e1..cd3bc49e7243 100644 --- a/packages/cli/cli-v2/src/commands/org/token/list/__test__/command.test.ts +++ b/packages/cli/cli-v2/src/commands/org/token/list/__test__/command.test.ts @@ -75,8 +75,8 @@ describe("ListTokensCommand", () => { const context = createMockContext(); await cmd.handle(context, { org: "acme" } as ListTokensCommand.Args); - expect(mockGet).toHaveBeenCalledWith({ orgId: "acme" }); - expect(mockGetTokens).toHaveBeenCalledWith({ organizationId: "org_abc123" }); + expect(mockGet).toHaveBeenCalledWith("acme"); + expect(mockGetTokens).toHaveBeenCalledWith("org_abc123"); expect(context.stdout.info).toHaveBeenCalledTimes(2); }); @@ -151,7 +151,7 @@ describe("ListTokensCommand", () => { const mockGet = vi.fn().mockResolvedValue({ ok: false, error: { - _visit: (visitor: { unprocessableEntityError: () => void }) => visitor.unprocessableEntityError() + _visit: (visitor: { unauthorizedError: () => void }) => visitor.unauthorizedError() } }); vi.mocked(createVenusService).mockReturnValue({ @@ -161,16 +161,40 @@ describe("ListTokensCommand", () => { const context = createMockContext(); await expect(cmd.handle(context, { org: "acme" } as ListTokensCommand.Args)).rejects.toThrow(CliError); - expect(context.stderr.error).toHaveBeenCalledWith(expect.stringContaining("was not found")); + expect(context.stderr.error).toHaveBeenCalledWith( + expect.stringContaining("do not have access to organization") + ); + }); + + it("should handle UnauthorizedError from getTokensForOrganization", async () => { + const { createVenusService } = await import("@fern-api/core"); + const mockGet = mockOrgLookupSuccess(); + const mockGetTokens = vi.fn().mockResolvedValue({ + ok: false, + error: { + _visit: (visitor: { unauthorizedError: () => void }) => visitor.unauthorizedError() + } + }); + vi.mocked(createVenusService).mockReturnValue({ + organization: { get: mockGet }, + apiKeys: { getTokensForOrganization: mockGetTokens } + } as unknown as ReturnType); + + const context = createMockContext(); + await expect(cmd.handle(context, { org: "acme" } as ListTokensCommand.Args)).rejects.toThrow(CliError); + + expect(context.stderr.error).toHaveBeenCalledWith( + expect.stringContaining("do not have access to organization") + ); }); - it("should handle UnprocessableEntityError from getTokensForOrganization", async () => { + it("should handle OrganizationNotFoundError", async () => { const { createVenusService } = await import("@fern-api/core"); const mockGet = mockOrgLookupSuccess(); const mockGetTokens = vi.fn().mockResolvedValue({ ok: false, error: { - _visit: (visitor: { unprocessableEntityError: () => void }) => visitor.unprocessableEntityError() + _visit: (visitor: { organizationNotFoundError: () => void }) => visitor.organizationNotFoundError() } }); vi.mocked(createVenusService).mockReturnValue({ diff --git a/packages/cli/cli-v2/src/commands/org/token/list/command.ts b/packages/cli/cli-v2/src/commands/org/token/list/command.ts index b75eef8b4157..17d9d5c9096a 100644 --- a/packages/cli/cli-v2/src/commands/org/token/list/command.ts +++ b/packages/cli/cli-v2/src/commands/org/token/list/command.ts @@ -28,15 +28,15 @@ export class ListTokensCommand { const venus = createVenusService({ token: token.value }); - const orgLookup = await venus.organization.get({ orgId: args.org }); + const orgLookup = await venus.organization.get(args.org); if (!orgLookup.ok) { orgLookup.error._visit({ - unprocessableEntityError: () => { - context.stderr.error(`${Icons.error} Organization "${args.org}" was not found.`); - throw CliError.notFound(); + unauthorizedError: () => { + context.stderr.error(`${Icons.error} You do not have access to organization "${args.org}".`); + throw new CliError({ code: CliError.Code.AuthError }); }, _other: () => { - context.stderr.error(`${Icons.error} Failed to look up organization "${args.org}".`); + context.stderr.error(`${Icons.error} Organization "${args.org}" was not found.`); throw CliError.notFound(); } }); @@ -46,12 +46,16 @@ export class ListTokensCommand { const response = await withSpinner({ message: `Fetching tokens for organization "${args.org}"`, - operation: () => venus.apiKeys.getTokensForOrganization({ organizationId: auth0OrgId }) + operation: () => venus.apiKeys.getTokensForOrganization(auth0OrgId) }); if (!response.ok) { response.error._visit({ - unprocessableEntityError: () => { + unauthorizedError: () => { + context.stderr.error(`${Icons.error} You do not have access to organization "${args.org}".`); + throw new CliError({ code: CliError.Code.AuthError }); + }, + organizationNotFoundError: () => { context.stderr.error(`${Icons.error} Organization "${args.org}" was not found.`); throw CliError.notFound(); }, diff --git a/packages/cli/cli-v2/src/commands/org/token/revoke/__test__/command.test.ts b/packages/cli/cli-v2/src/commands/org/token/revoke/__test__/command.test.ts index 394907fdd6e5..c503fad2ebec 100644 --- a/packages/cli/cli-v2/src/commands/org/token/revoke/__test__/command.test.ts +++ b/packages/cli/cli-v2/src/commands/org/token/revoke/__test__/command.test.ts @@ -50,7 +50,7 @@ describe("RevokeTokenCommand", () => { const context = createMockContext(); await cmd.handle(context, { tokenId: "tok_123" } as RevokeTokenCommand.Args); - expect(mockRevoke).toHaveBeenCalledWith({ tokenId: "tok_123" }); + expect(mockRevoke).toHaveBeenCalledWith("tok_123"); expect(context.stderr.info).toHaveBeenCalledWith(expect.stringContaining("has been revoked")); }); @@ -80,12 +80,30 @@ describe("RevokeTokenCommand", () => { ); }); - it("should handle UnprocessableEntityError", async () => { + it("should handle UnauthorizedError", async () => { const { createVenusService } = await import("@fern-api/core"); const mockRevoke = vi.fn().mockResolvedValue({ ok: false, error: { - _visit: (visitor: { unprocessableEntityError: () => void }) => visitor.unprocessableEntityError() + _visit: (visitor: { unauthorizedError: () => void }) => visitor.unauthorizedError() + } + }); + vi.mocked(createVenusService).mockReturnValue({ + apiKeys: { revokeTokenById: mockRevoke } + } as unknown as ReturnType); + + const context = createMockContext(); + await expect(cmd.handle(context, { tokenId: "tok_123" } as RevokeTokenCommand.Args)).rejects.toThrow(CliError); + + expect(context.stderr.error).toHaveBeenCalledWith(expect.stringContaining("not authorized to revoke")); + }); + + it("should handle TokenNotFoundError", async () => { + const { createVenusService } = await import("@fern-api/core"); + const mockRevoke = vi.fn().mockResolvedValue({ + ok: false, + error: { + _visit: (visitor: { tokenNotFoundError: () => void }) => visitor.tokenNotFoundError() } }); vi.mocked(createVenusService).mockReturnValue({ diff --git a/packages/cli/cli-v2/src/commands/org/token/revoke/command.ts b/packages/cli/cli-v2/src/commands/org/token/revoke/command.ts index bb4017b9162d..16b18cf31915 100644 --- a/packages/cli/cli-v2/src/commands/org/token/revoke/command.ts +++ b/packages/cli/cli-v2/src/commands/org/token/revoke/command.ts @@ -31,7 +31,7 @@ export class RevokeTokenCommand { const response = await withSpinner({ message: `Revoking token "${tokenId}"`, - operation: () => venus.apiKeys.revokeTokenById({ tokenId }) + operation: () => venus.apiKeys.revokeTokenById(tokenId) }); if (response.ok) { @@ -44,7 +44,11 @@ export class RevokeTokenCommand { } response.error._visit({ - unprocessableEntityError: () => { + unauthorizedError: () => { + context.stderr.error(`${Icons.error} You are not authorized to revoke this token.`); + throw new CliError({ code: CliError.Code.AuthError }); + }, + tokenNotFoundError: () => { context.stderr.error(`${Icons.error} Token "${tokenId}" was not found.`); throw CliError.notFound(); }, diff --git a/packages/cli/cli/changes/5.37.1/fix-login-browser-open-fallback.yml b/packages/cli/cli/changes/5.37.1/fix-login-browser-open-fallback.yml new file mode 100644 index 000000000000..7cecdfe1cca1 --- /dev/null +++ b/packages/cli/cli/changes/5.37.1/fix-login-browser-open-fallback.yml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Prevent login from reporting missing browser opener tools as internal errors. + Upgrade `open` to v11 so spawn failures reject the returned promise, and handle + them inline in redirect, device-code, and logout flows. + type: fix diff --git a/packages/cli/cli/changes/5.37.2/fix-absolute-openapi-paths.yml b/packages/cli/cli/changes/5.37.2/fix-absolute-openapi-paths.yml new file mode 100644 index 000000000000..6e2b09d1d3a1 --- /dev/null +++ b/packages/cli/cli/changes/5.37.2/fix-absolute-openapi-paths.yml @@ -0,0 +1,5 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Report absolute OpenAPI spec paths as user-facing workspace configuration errors. + type: fix diff --git a/packages/cli/cli/changes/5.37.2/theme-export-settings.yml b/packages/cli/cli/changes/5.37.2/theme-export-settings.yml new file mode 100644 index 000000000000..a4102e0af7bf --- /dev/null +++ b/packages/cli/cli/changes/5.37.2/theme-export-settings.yml @@ -0,0 +1,3 @@ +- summary: | + Include `settings` in theme export so that `fern docs theme export` captures the settings configuration from docs.yml. + type: fix diff --git a/packages/cli/cli/changes/5.37.3/fix-mintlify-navigation-config-error.yml b/packages/cli/cli/changes/5.37.3/fix-mintlify-navigation-config-error.yml new file mode 100644 index 000000000000..60a396b30d81 --- /dev/null +++ b/packages/cli/cli/changes/5.37.3/fix-mintlify-navigation-config-error.yml @@ -0,0 +1,3 @@ +- summary: | + Report invalid Mintlify navigation configuration as a user-facing config error during docs import. + type: fix diff --git a/packages/cli/cli/changes/5.37.4/revert-venus-sdk-bump.yml b/packages/cli/cli/changes/5.37.4/revert-venus-sdk-bump.yml new file mode 100644 index 000000000000..42dc5c3c9b36 --- /dev/null +++ b/packages/cli/cli/changes/5.37.4/revert-venus-sdk-bump.yml @@ -0,0 +1,5 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Revert the @fern-api/venus-api-sdk bump from 5.35.5 (0.22.34 -> 4.0.0). + type: chore diff --git a/packages/cli/cli/changes/5.37.5/fix-docs-preview-watcher-errors.yml b/packages/cli/cli/changes/5.37.5/fix-docs-preview-watcher-errors.yml new file mode 100644 index 000000000000..262bd080bd1f --- /dev/null +++ b/packages/cli/cli/changes/5.37.5/fix-docs-preview-watcher-errors.yml @@ -0,0 +1,5 @@ +# yaml-language-server: $schema=../../../../../fern-changes-yml.schema.json + +- summary: | + Handle docs preview file watcher startup failures as local environment errors. + type: fix diff --git a/packages/cli/cli/changes/5.37.6/consolidate-theme-eligible-fields.yml b/packages/cli/cli/changes/5.37.6/consolidate-theme-eligible-fields.yml new file mode 100644 index 000000000000..4ae06f65d286 --- /dev/null +++ b/packages/cli/cli/changes/5.37.6/consolidate-theme-eligible-fields.yml @@ -0,0 +1,5 @@ +- summary: | + Centralize theme-eligible docs.yml field definitions in @fern-api/configuration + so theme export, upload, and global theme stitching reference the same source + of truth. + type: chore diff --git a/packages/cli/cli/changes/5.37.7/bump-fdr-sdk.yml b/packages/cli/cli/changes/5.37.7/bump-fdr-sdk.yml new file mode 100644 index 000000000000..a1b65849acdc --- /dev/null +++ b/packages/cli/cli/changes/5.37.7/bump-fdr-sdk.yml @@ -0,0 +1,4 @@ +- summary: | + Bump @fern-api/fdr-sdk to 1.2.16 to fix crash when generating examples for + endpoints that reference types excluded by audience filtering. + type: fix diff --git a/packages/cli/cli/package.json b/packages/cli/cli/package.json index f346014afcb7..f2a8625d2a9d 100644 --- a/packages/cli/cli/package.json +++ b/packages/cli/cli/package.json @@ -70,7 +70,7 @@ "@fern-api/docs-preview": "workspace:*", "@fern-api/docs-resolver": "workspace:*", "@fern-api/docs-validator": "workspace:*", - "@fern-api/fdr-sdk": "1.2.4-f661387fb2", + "@fern-api/fdr-sdk": "1.2.16-3b754a56be", "@fern-api/fern-definition-formatter": "workspace:*", "@fern-api/fern-definition-schema": "workspace:*", "@fern-api/fern-definition-validator": "workspace:*", diff --git a/packages/cli/cli/src/commands/docs-theme/ThemeExporter.ts b/packages/cli/cli/src/commands/docs-theme/ThemeExporter.ts index d0b8007dfd83..2cf5bf87bfba 100644 --- a/packages/cli/cli/src/commands/docs-theme/ThemeExporter.ts +++ b/packages/cli/cli/src/commands/docs-theme/ThemeExporter.ts @@ -1,3 +1,4 @@ +import { docsYml } from "@fern-api/configuration"; import { isURL } from "@fern-api/fs-utils"; import { TaskContext } from "@fern-api/task-context"; import { DocsWorkspace } from "@fern-api/workspace-loader"; @@ -5,23 +6,6 @@ import { copyFile, mkdir, readFile, writeFile } from "fs/promises"; import yaml from "js-yaml"; import path from "path"; -const THEME_ELIGIBLE_KEYS = new Set([ - "logo", - "favicon", - "colors", - "typography", - "layout", - "navbar-links", - "footer-links", - "background-image", - "theme", - "css", - "js", - "header", - "footer", - "metadata" -]); - export class ThemeExporter { public constructor(private readonly docsWorkspace: DocsWorkspace) {} @@ -38,7 +22,7 @@ export class ThemeExporter { const raw = yaml.load(await readFile(docsYmlPath, "utf-8")) as Record; const themeConfig: Record = {}; for (const [k, v] of Object.entries(raw)) { - if (THEME_ELIGIBLE_KEYS.has(k)) { + if (docsYml.THEME_ELIGIBLE_YAML_KEYS.has(k)) { themeConfig[k] = v; } } diff --git a/packages/cli/cli/src/commands/token/token.ts b/packages/cli/cli/src/commands/token/token.ts index 8a95ee804904..4d7632f0eec5 100644 --- a/packages/cli/cli/src/commands/token/token.ts +++ b/packages/cli/cli/src/commands/token/token.ts @@ -24,12 +24,24 @@ export async function generateToken({ return; } response.error._visit({ - unprocessableEntityError: () => + organizationNotFoundError: () => taskContext.failAndThrow( `Failed to create token because the organization ${orgId} was not found. Please reach out to support@buildwithfern.com`, undefined, { code: CliError.Code.AuthError } ), + unauthorizedError: () => + taskContext.failAndThrow( + `Failed to create token because you are not in the ${orgId} organization. Please reach out to support@buildwithfern.com`, + undefined, + { code: CliError.Code.AuthError } + ), + missingOrgPermissionsError: () => + taskContext.failAndThrow( + `Failed to create token because you do not have the required permissions in the ${orgId} organization. Please reach out to support@buildwithfern.com`, + undefined, + { code: CliError.Code.AuthError } + ), _other: () => taskContext.failAndThrow( "Failed to create token. Please reach out to support@buildwithfern.com", diff --git a/packages/cli/cli/versions.yml b/packages/cli/cli/versions.yml index 717ba6adbccd..7e38e1a390c8 100644 --- a/packages/cli/cli/versions.yml +++ b/packages/cli/cli/versions.yml @@ -1,4 +1,61 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 5.37.7 + changelogEntry: + - summary: | + Bump @fern-api/fdr-sdk to 1.2.16 to fix crash when generating examples for + endpoints that reference types excluded by audience filtering. + type: fix + createdAt: "2026-05-22" + irVersion: 66 +- version: 5.37.6 + changelogEntry: + - summary: | + Centralize theme-eligible docs.yml field definitions in @fern-api/configuration + so theme export, upload, and global theme stitching reference the same source + of truth. + type: chore + createdAt: "2026-05-22" + irVersion: 66 +- version: 5.37.5 + changelogEntry: + - summary: | + Handle docs preview file watcher startup failures as local environment errors. + type: fix + createdAt: "2026-05-22" + irVersion: 66 +- version: 5.37.4 + changelogEntry: + - summary: | + Revert the @fern-api/venus-api-sdk bump from 5.35.5 (0.22.34 -> 4.0.0). + type: chore + createdAt: "2026-05-22" + irVersion: 66 +- version: 5.37.3 + changelogEntry: + - summary: | + Report invalid Mintlify navigation configuration as a user-facing config error during docs import. + type: fix + createdAt: "2026-05-22" + irVersion: 66 +- version: 5.37.2 + changelogEntry: + - summary: | + Report absolute OpenAPI spec paths as user-facing workspace configuration errors. + type: fix + - summary: | + Include `settings` in theme export so that `fern docs theme export` captures the settings configuration from docs.yml. + type: fix + createdAt: "2026-05-22" + irVersion: 66 +- version: 5.37.1 + changelogEntry: + - summary: | + Prevent login from reporting missing browser opener tools as internal errors. + Upgrade `open` to v11 so spawn failures reject the returned promise, and handle + them inline in redirect, device-code, and logout flows. + type: fix + createdAt: "2026-05-22" + irVersion: 66 - version: 5.37.0 changelogEntry: - summary: | diff --git a/packages/cli/configuration-loader/package.json b/packages/cli/configuration-loader/package.json index 3ebe4878fc45..9cd0a86684ae 100644 --- a/packages/cli/configuration-loader/package.json +++ b/packages/cli/configuration-loader/package.json @@ -34,7 +34,7 @@ "dependencies": { "@fern-api/configuration": "workspace:*", "@fern-api/core-utils": "workspace:*", - "@fern-api/fdr-sdk": "1.2.4-f661387fb2", + "@fern-api/fdr-sdk": "1.2.16-3b754a56be", "@fern-api/fs-utils": "workspace:*", "@fern-api/github": "workspace:*", "@fern-api/logging-execa": "workspace:*", diff --git a/packages/cli/configuration/package.json b/packages/cli/configuration/package.json index d8f6d4875bc1..992f715d488c 100644 --- a/packages/cli/configuration/package.json +++ b/packages/cli/configuration/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@fern-api/core-utils": "workspace:*", - "@fern-api/fdr-sdk": "1.2.4-f661387fb2", + "@fern-api/fdr-sdk": "1.2.16-3b754a56be", "@fern-api/fern-definition-schema": "workspace:*", "@fern-api/path-utils": "workspace:*", "@fern-fern/fiddle-sdk": "catalog:", diff --git a/packages/cli/configuration/src/docs-yml/index.ts b/packages/cli/configuration/src/docs-yml/index.ts index 7e399fdd6167..898410b83caf 100644 --- a/packages/cli/configuration/src/docs-yml/index.ts +++ b/packages/cli/configuration/src/docs-yml/index.ts @@ -1,3 +1,4 @@ export * as DocsYmlSchemas from "./DocsYmlSchemas.js"; export * from "./ParsedDocsConfiguration.js"; export * as RawSchemas from "./schemas/index.js"; +export * from "./themeEligibleFields.js"; diff --git a/packages/cli/configuration/src/docs-yml/themeEligibleFields.ts b/packages/cli/configuration/src/docs-yml/themeEligibleFields.ts new file mode 100644 index 000000000000..5639a24c4e48 --- /dev/null +++ b/packages/cli/configuration/src/docs-yml/themeEligibleFields.ts @@ -0,0 +1,38 @@ +// "global" — the theme value always wins; local docs.yml cannot override it. +// "local" — the local docs.yml value wins when present; theme is the fallback. +export type ThemeFieldPolicy = "global" | "local"; + +// Controls, per eligible key, whether the global theme takes precedence or the +// local docs.yml can override. Add new theme-eligible keys here. +// Keys use the camelCase form that DocsConfiguration uses internally. +export const THEME_FIELD_POLICIES = { + logo: "global", + favicon: "global", + backgroundImage: "global", + colors: "global", + typography: "global", + layout: "global", + settings: "global", + theme: "global", + integrations: "global", + css: "global", + js: "global", + header: "global", + footer: "global", + navbarLinks: "global", + footerLinks: "global", + aiSearch: "global", + announcement: "global", + metadata: "global" +} as const satisfies Record; + +export type ThemeEligibleField = keyof typeof THEME_FIELD_POLICIES; + +export const THEME_ELIGIBLE_FIELDS = Object.keys(THEME_FIELD_POLICIES) as ThemeEligibleField[]; + +function camelToKebab(value: string): string { + return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(); +} + +/** Kebab-case top-level keys as they appear in raw docs.yml / theme.yml. */ +export const THEME_ELIGIBLE_YAML_KEYS = new Set(THEME_ELIGIBLE_FIELDS.map(camelToKebab)); diff --git a/packages/cli/docs-importers/commons/package.json b/packages/cli/docs-importers/commons/package.json index 019eaf80f8a6..55fc655fe5ae 100644 --- a/packages/cli/docs-importers/commons/package.json +++ b/packages/cli/docs-importers/commons/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@fern-api/configuration": "workspace:*", - "@fern-api/fdr-sdk": "1.2.4-f661387fb2", + "@fern-api/fdr-sdk": "1.2.16-3b754a56be", "@fern-api/fs-utils": "workspace:*", "@fern-api/task-context": "workspace:*", "js-yaml": "catalog:" diff --git a/packages/cli/docs-importers/mintlify/package.json b/packages/cli/docs-importers/mintlify/package.json index ccbf696af80d..40b87fbfce42 100644 --- a/packages/cli/docs-importers/mintlify/package.json +++ b/packages/cli/docs-importers/mintlify/package.json @@ -35,7 +35,7 @@ "@fern-api/configuration": "workspace:*", "@fern-api/core-utils": "workspace:*", "@fern-api/docs-importer-commons": "workspace:*", - "@fern-api/fdr-sdk": "1.2.4-f661387fb2", + "@fern-api/fdr-sdk": "1.2.16-3b754a56be", "@fern-api/fs-utils": "workspace:*", "@fern-api/logger": "workspace:*", "@fern-api/task-context": "workspace:*", diff --git a/packages/cli/docs-importers/mintlify/src/MintlifyImporter.ts b/packages/cli/docs-importers/mintlify/src/MintlifyImporter.ts index 8de427e0fb47..7db21537e3bb 100644 --- a/packages/cli/docs-importers/mintlify/src/MintlifyImporter.ts +++ b/packages/cli/docs-importers/mintlify/src/MintlifyImporter.ts @@ -23,13 +23,24 @@ export declare namespace MintlifyImporter { } } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value != null && !Array.isArray(value); +} + +function hasNavigationArray(value: unknown): value is MintJsonSchema { + return isRecord(value) && Array.isArray(value.navigation); +} + export class MintlifyImporter extends DocsImporter { private documentationTab: TabInfo | undefined = undefined; private tabUrlToInfo: Record = {}; public async import({ args, builder }: { args: MintlifyImporter.Args; builder: FernDocsBuilder }): Promise { const mintJsonContent = await readFile(args.absolutePathToMintJson, "utf-8"); - const mint = JSON.parse(mintJsonContent) as MintJsonSchema; + const mint = this.parseMintJson({ + content: mintJsonContent, + absolutePathToMintJson: args.absolutePathToMintJson + }); builder.setTitle({ title: mint.name }); @@ -145,6 +156,31 @@ export class MintlifyImporter extends DocsImporter { this.context.logger.debug(`Added instance ${instanceUrl} to docs.yml`); } + private parseMintJson({ + content, + absolutePathToMintJson + }: { + content: string; + absolutePathToMintJson: AbsoluteFilePath; + }): MintJsonSchema { + let mintJson: unknown; + try { + mintJson = JSON.parse(content) as unknown; + } catch (error) { + return this.context.failAndThrow(`Failed to parse ${absolutePathToMintJson}.`, error, { + code: CliError.Code.ParseError + }); + } + + if (!hasNavigationArray(mintJson)) { + return this.context.failAndThrow("Expected navigation in mint.json to be an array.", undefined, { + code: CliError.Code.ConfigError + }); + } + + return mintJson; + } + private async getNavigationBuilder({ mintItem, builder diff --git a/packages/cli/docs-importers/mintlify/src/__test__/migrateFromMintlify.test.ts b/packages/cli/docs-importers/mintlify/src/__test__/migrateFromMintlify.test.ts index e724e34f9f04..2125e3329c60 100644 --- a/packages/cli/docs-importers/mintlify/src/__test__/migrateFromMintlify.test.ts +++ b/packages/cli/docs-importers/mintlify/src/__test__/migrateFromMintlify.test.ts @@ -1,8 +1,10 @@ import { AbsoluteFilePath, doesPathExist, join, RelativeFilePath } from "@fern-api/fs-utils"; import { CONSOLE_LOGGER } from "@fern-api/logger"; -import { createMockTaskContext } from "@fern-api/task-context"; -import { mkdir, rm } from "fs/promises"; +import { CliError, createMockTaskContext } from "@fern-api/task-context"; +import { mkdir, mkdtemp, rm, writeFile } from "fs/promises"; +import os from "os"; import path from "path"; +import { vi } from "vitest"; import { runMintlifyMigration } from "../runMintlifyMigration.js"; @@ -36,4 +38,41 @@ describe("add-generator-groups", () => { }); }); } + + it("throws a config error when navigation is missing from mint.json", async () => { + const fixturePath = AbsoluteFilePath.of(await mkdtemp(path.join(os.tmpdir(), "mintlify-invalid-navigation-"))); + const absolutePathToMintJson = join(fixturePath, RelativeFilePath.of("mint.json")); + + try { + await writeFile( + absolutePathToMintJson, + JSON.stringify({ + name: "Invalid Mintlify docs", + favicon: "/favicon.ico", + colors: { + primary: "#000000" + } + }) + ); + + const taskContext = createMockTaskContext({ logger: CONSOLE_LOGGER }); + const failAndThrow = vi.spyOn(taskContext, "failAndThrow"); + + await expect( + runMintlifyMigration({ + absolutePathToMintJson, + outputPath: fixturePath, + taskContext, + versionOfCli: "*", + organization: "fern" + }) + ).rejects.toThrow(); + + expect(failAndThrow).toHaveBeenCalledWith("Expected navigation in mint.json to be an array.", undefined, { + code: CliError.Code.ConfigError + }); + } finally { + await rm(fixturePath, { recursive: true, force: true }); + } + }); }); diff --git a/packages/cli/docs-importers/readme/package.json b/packages/cli/docs-importers/readme/package.json index 7a4d14e4a8a1..ea5a1607d621 100644 --- a/packages/cli/docs-importers/readme/package.json +++ b/packages/cli/docs-importers/readme/package.json @@ -35,7 +35,7 @@ "@fern-api/configuration": "workspace:*", "@fern-api/core-utils": "workspace:*", "@fern-api/docs-importer-commons": "workspace:*", - "@fern-api/fdr-sdk": "1.2.4-f661387fb2", + "@fern-api/fdr-sdk": "1.2.16-3b754a56be", "@fern-api/fs-utils": "workspace:*", "@fern-api/logger": "workspace:*", "@fern-api/task-context": "workspace:*", diff --git a/packages/cli/docs-markdown-utils/package.json b/packages/cli/docs-markdown-utils/package.json index 326bb791b17d..cff6f3545c6e 100644 --- a/packages/cli/docs-markdown-utils/package.json +++ b/packages/cli/docs-markdown-utils/package.json @@ -32,7 +32,7 @@ "test:update": "vitest --run -u" }, "dependencies": { - "@fern-api/fdr-sdk": "1.2.4-f661387fb2", + "@fern-api/fdr-sdk": "1.2.16-3b754a56be", "@fern-api/fs-utils": "workspace:*", "@fern-api/task-context": "workspace:*", "estree-walker": "catalog:", diff --git a/packages/cli/docs-preview/package.json b/packages/cli/docs-preview/package.json index c9b5629c4a76..afa7d5f863b7 100644 --- a/packages/cli/docs-preview/package.json +++ b/packages/cli/docs-preview/package.json @@ -36,7 +36,7 @@ "@fern-api/core-utils": "workspace:*", "@fern-api/docs-markdown-utils": "workspace:*", "@fern-api/docs-resolver": "workspace:*", - "@fern-api/fdr-sdk": "1.2.4-f661387fb2", + "@fern-api/fdr-sdk": "1.2.16-3b754a56be", "@fern-api/fs-utils": "workspace:*", "@fern-api/ir-sdk": "workspace:*", "@fern-api/ir-utils": "workspace:*", diff --git a/packages/cli/docs-preview/src/createDocsPreviewWatcher.ts b/packages/cli/docs-preview/src/createDocsPreviewWatcher.ts new file mode 100644 index 000000000000..dd662390132d --- /dev/null +++ b/packages/cli/docs-preview/src/createDocsPreviewWatcher.ts @@ -0,0 +1,70 @@ +import { AbsoluteFilePath } from "@fern-api/fs-utils"; +import { CliError, TaskContext } from "@fern-api/task-context"; + +import Watcher from "watcher"; + +function isErrnoError(error: Error): error is Error & { code: string } { + return "code" in error && typeof error.code === "string"; +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function getWatcherErrorMessage(error: Error): string { + if (isErrnoError(error) && error.code === "ENOSPC") { + return ( + "Unable to watch Fern docs files for changes because the system limit for file watchers was reached. " + + "Close other file-watching processes or increase the file watcher limit, then restart `fern docs dev`." + ); + } + return `Unable to watch Fern docs files for changes: ${error.message}`; +} + +export async function createDocsPreviewWatcher({ + absoluteFilePathToFern, + additionalFilepaths, + context +}: { + absoluteFilePathToFern: AbsoluteFilePath; + additionalFilepaths: AbsoluteFilePath[]; + context: TaskContext; +}): Promise { + const watcher = new Watcher([absoluteFilePathToFern, ...additionalFilepaths], { + recursive: true, + ignoreInitial: true, + debounce: 100, + renameDetection: true + }); + + const initializationError = await new Promise((resolve) => { + const handleReady = () => { + watcher.off("error", handleError); + resolve(undefined); + }; + const handleError = (error: unknown) => { + watcher.off("ready", handleReady); + resolve(toError(error)); + }; + + watcher.once("ready", handleReady); + watcher.once("error", handleError); + }); + + if (initializationError != null) { + watcher.close(); + context.failAndThrow(getWatcherErrorMessage(initializationError), initializationError, { + code: CliError.Code.EnvironmentError + }); + } + + watcher.on("error", (error: unknown) => { + const watcherError = toError(error); + context.failWithoutThrowing(getWatcherErrorMessage(watcherError), watcherError, { + code: CliError.Code.EnvironmentError + }); + watcher.close(); + }); + + return watcher; +} diff --git a/packages/cli/docs-preview/src/runAppPreviewServer.ts b/packages/cli/docs-preview/src/runAppPreviewServer.ts index 47808250930a..1f87424700a3 100644 --- a/packages/cli/docs-preview/src/runAppPreviewServer.ts +++ b/packages/cli/docs-preview/src/runAppPreviewServer.ts @@ -33,9 +33,9 @@ import { readFile, rm } from "fs/promises"; import http, { type IncomingMessage } from "http"; import path from "path"; import { type Duplex } from "stream"; -import Watcher from "watcher"; import { WebSocket, WebSocketServer } from "ws"; import { type BunServer, createBunServer } from "./createBunServer.js"; +import { createDocsPreviewWatcher } from "./createDocsPreviewWatcher.js"; import { DebugLogger } from "./DebugLogger.js"; import { downloadBundle, getPathToBundleFolder, getPathToPreviewFolder } from "./downloadLocalDocsBundle.js"; import { writeNodePolyfillScript } from "./nodePolyfills.js"; @@ -980,11 +980,10 @@ export async function runAppPreviewServer({ const additionalFilepaths = project.apiWorkspaces.flatMap((workspace) => workspace.getAbsoluteFilePaths()); // Create watcher but don't attach the event handler yet - we'll do that after the Next.js server starts - const watcher = new Watcher([absoluteFilePathToFern, ...additionalFilepaths], { - recursive: true, - ignoreInitial: true, - debounce: 100, - renameDetection: true + const watcher = await createDocsPreviewWatcher({ + absoluteFilePathToFern, + additionalFilepaths, + context }); const editedAbsoluteFilepaths: AbsoluteFilePath[] = []; diff --git a/packages/cli/docs-preview/src/runPreviewServer.ts b/packages/cli/docs-preview/src/runPreviewServer.ts index b80fb78c2b03..15bdfc1092ce 100644 --- a/packages/cli/docs-preview/src/runPreviewServer.ts +++ b/packages/cli/docs-preview/src/runPreviewServer.ts @@ -20,9 +20,9 @@ import express from "express"; import { readFile } from "fs/promises"; import http from "http"; import path from "path"; -import Watcher from "watcher"; import { type WebSocket, WebSocketServer } from "ws"; +import { createDocsPreviewWatcher } from "./createDocsPreviewWatcher.js"; import { downloadBundle, getPathToBundleFolder } from "./downloadLocalDocsBundle.js"; import { getPreviewDocsDefinition, type PreviewDocsResult } from "./previewDocs.js"; @@ -352,11 +352,10 @@ export async function runPreviewServer({ const additionalFilepaths = project.apiWorkspaces.flatMap((workspace) => workspace.getAbsoluteFilePaths()); const bundleRoot = bundlePath ? AbsoluteFilePath.of(path.resolve(bundlePath)) : getPathToBundleFolder({ cacheDir }); - const watcher = new Watcher([absoluteFilePathToFern, ...additionalFilepaths], { - recursive: true, - ignoreInitial: true, - debounce: 100, - renameDetection: true + const watcher = await createDocsPreviewWatcher({ + absoluteFilePathToFern, + additionalFilepaths, + context }); const editedAbsoluteFilepaths: AbsoluteFilePath[] = []; diff --git a/packages/cli/docs-resolver/src/stitchGlobalTheme.ts b/packages/cli/docs-resolver/src/stitchGlobalTheme.ts index d62479f367ab..e5994c4a56f1 100644 --- a/packages/cli/docs-resolver/src/stitchGlobalTheme.ts +++ b/packages/cli/docs-resolver/src/stitchGlobalTheme.ts @@ -214,35 +214,7 @@ export function deepMergeGlobalWins( return result; } -// "global" — the theme value always wins; local docs.yml cannot override it. -// "local" — the local docs.yml value wins when present; theme is the fallback. -type ThemeFieldPolicy = "global" | "local"; - -// Controls, per eligible key, whether the global theme takes precedence or the -// local docs.yml can override. Add new theme-eligible keys here. -// Keys use the camelCase form that DocsConfiguration uses internally. -const THEME_FIELD_POLICIES: Readonly> = { - logo: "global", - favicon: "global", - backgroundImage: "global", - colors: "global", - typography: "global", - layout: "global", - settings: "global", - theme: "global", - integrations: "global", - css: "global", - js: "global", - header: "global", - footer: "global", - navbarLinks: "global", - footerLinks: "global", - aiSearch: "global", - announcement: "global", - metadata: "global" -}; - -const THEME_ELIGIBLE_KEYS = Object.keys(THEME_FIELD_POLICIES) as ReadonlyArray; +const { THEME_ELIGIBLE_FIELDS, THEME_FIELD_POLICIES } = docsYml; function kebabToCamel(str: string): string { return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); @@ -276,7 +248,7 @@ export function mergeThemeOverride(local: RawDocsConfig, themeOverride: Record; const merged: Record = { ...localRecord }; - for (const key of THEME_ELIGIBLE_KEYS) { + for (const key of THEME_ELIGIBLE_FIELDS) { const themeValue = normalized[key]; const localValue = localRecord[key]; const policy = THEME_FIELD_POLICIES[key] ?? "global"; diff --git a/packages/cli/ete-tests/package.json b/packages/cli/ete-tests/package.json index a587ee056778..21eeb549504a 100644 --- a/packages/cli/ete-tests/package.json +++ b/packages/cli/ete-tests/package.json @@ -35,7 +35,7 @@ }, "dependencies": { "@fern-api/configuration": "workspace:*", - "@fern-api/fdr-sdk": "1.2.4-f661387fb2", + "@fern-api/fdr-sdk": "1.2.16-3b754a56be", "@fern-api/fs-utils": "workspace:*", "@fern-api/lazy-fern-workspace": "workspace:*", "@fern-api/logger": "workspace:*", diff --git a/packages/cli/generation/local-generation/local-workspace-runner/src/runLocalGenerationForWorkspace.ts b/packages/cli/generation/local-generation/local-workspace-runner/src/runLocalGenerationForWorkspace.ts index a7d09b2b9737..ae6032893ded 100644 --- a/packages/cli/generation/local-generation/local-workspace-runner/src/runLocalGenerationForWorkspace.ts +++ b/packages/cli/generation/local-generation/local-workspace-runner/src/runLocalGenerationForWorkspace.ts @@ -202,7 +202,7 @@ export async function runLocalGenerationForWorkspace({ } } - const organization = await venus.organization.get({ orgId: projectConfig.organization }); + const organization = await venus.organization.get(projectConfig.organization); if (generatorInvocation.absolutePathToLocalOutput == null && !organization.ok) { interactiveTaskContext.failWithoutThrowing( diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/package.json b/packages/cli/generation/remote-generation/remote-workspace-runner/package.json index 9078cc01cfb9..5dbb1cbdcb54 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/package.json +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/package.json @@ -42,7 +42,7 @@ "@fern-api/core-utils": "workspace:*", "@fern-api/docs-resolver": "workspace:*", "@fern-api/fai-sdk": "catalog:", - "@fern-api/fdr-sdk": "1.2.4-f661387fb2", + "@fern-api/fdr-sdk": "1.2.16-3b754a56be", "@fern-api/fs-utils": "workspace:*", "@fern-api/generator-cli": "workspace:*", "@fern-api/ir-generator": "workspace:*", diff --git a/packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForGenerator.ts b/packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForGenerator.ts index 3a6cabc08177..f5a409d35a38 100644 --- a/packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForGenerator.ts +++ b/packages/cli/generation/remote-generation/remote-workspace-runner/src/runRemoteGenerationForGenerator.ts @@ -194,7 +194,7 @@ export async function runRemoteGenerationForGenerator({ const venus = createVenusService({ token: token.value }); if (!isAirGapped) { - const orgResponse = await venus.organization.get({ orgId: projectConfig.organization }); + const orgResponse = await venus.organization.get(projectConfig.organization); if (orgResponse.ok) { if (orgResponse.body.isWhitelabled) { diff --git a/packages/cli/login/src/auth0-login/doAuth0DeviceAuthorizationFlow.ts b/packages/cli/login/src/auth0-login/doAuth0DeviceAuthorizationFlow.ts index 3913064936c5..9f58b1f24350 100644 --- a/packages/cli/login/src/auth0-login/doAuth0DeviceAuthorizationFlow.ts +++ b/packages/cli/login/src/auth0-login/doAuth0DeviceAuthorizationFlow.ts @@ -51,7 +51,11 @@ export async function doAuth0DeviceAuthorizationFlow({ context.failAndThrow("Failed to authenticate", deviceCodeResponse.data, { code: CliError.Code.AuthError }); } - await open(deviceCodeResponse.data.verification_uri_complete); + try { + await open(deviceCodeResponse.data.verification_uri_complete); + } catch { + // URL and login code are printed below. + } context.logger.info( [ diff --git a/packages/cli/login/src/auth0-login/doAuth0LoginFlow.ts b/packages/cli/login/src/auth0-login/doAuth0LoginFlow.ts index ec4e059226c6..c56b2b43fb27 100644 --- a/packages/cli/login/src/auth0-login/doAuth0LoginFlow.ts +++ b/packages/cli/login/src/auth0-login/doAuth0LoginFlow.ts @@ -1,4 +1,4 @@ -import { CliError } from "@fern-api/task-context"; +import { CliError, type TaskContext } from "@fern-api/task-context"; import axios from "axios"; import { IncomingMessage, Server } from "http"; import open from "open"; @@ -19,12 +19,14 @@ export interface Auth0TokenResponse { } export async function doAuth0LoginFlow({ + context, auth0Domain, auth0ClientId, audience, forceReauth = false, connection }: { + context: TaskContext; auth0Domain: string; auth0ClientId: string; audience: string; @@ -34,12 +36,22 @@ export async function doAuth0LoginFlow({ connection?: string; }): Promise { const { origin, server } = await createServer(); - const { code } = await getCode({ server, auth0Domain, auth0ClientId, origin, audience, forceReauth, connection }); + const { code } = await getCode({ + context, + server, + auth0Domain, + auth0ClientId, + origin, + audience, + forceReauth, + connection + }); server.close(); return await getTokenFromCode({ auth0Domain, auth0ClientId, code, origin }); } function getCode({ + context, server, auth0Domain, auth0ClientId, @@ -48,6 +60,7 @@ function getCode({ forceReauth, connection }: { + context: TaskContext; server: Server; auth0Domain: string; auth0ClientId: string; @@ -57,8 +70,7 @@ function getCode({ connection?: string; }) { return new Promise<{ code: string }>((resolve) => { - // eslint-disable-next-line @typescript-eslint/no-misused-promises - server.addListener("request", async (request, response) => { + server.addListener("request", (request, response) => { const code = parseCodeFromUrl(request, origin); if (code == null) { request.socket.end(); @@ -70,7 +82,18 @@ function getCode({ } }); - void open(constructAuth0Url({ auth0ClientId, auth0Domain, origin, audience, forceReauth, connection })); + const loginUrl = constructAuth0Url({ auth0ClientId, auth0Domain, origin, audience, forceReauth, connection }); + void open(loginUrl).catch(() => { + context.logger.info( + [ + "", + "Couldn't open a browser automatically.", + "If you're running fern on this machine, open this link to log in:", + loginUrl, + "" + ].join("\n") + ); + }); }); } diff --git a/packages/cli/login/src/login.ts b/packages/cli/login/src/login.ts index 0e4cebf9e7f1..26514e5f2c39 100644 --- a/packages/cli/login/src/login.ts +++ b/packages/cli/login/src/login.ts @@ -55,6 +55,7 @@ export async function getTokenFromAuth0( email }); return await doAuth0LoginFlow({ + context, auth0Domain: AUTH0_DOMAIN, auth0ClientId: AUTH0_CLIENT_ID, audience: VENUS_AUDIENCE, @@ -64,6 +65,7 @@ export async function getTokenFromAuth0( try { return await doAuth0LoginFlow({ + context, auth0Domain: AUTH0_DOMAIN, auth0ClientId: AUTH0_CLIENT_ID, audience: VENUS_AUDIENCE, diff --git a/packages/cli/register/package.json b/packages/cli/register/package.json index f0007cc5ce7e..28801437f6d4 100644 --- a/packages/cli/register/package.json +++ b/packages/cli/register/package.json @@ -38,7 +38,7 @@ "@fern-api/configuration": "workspace:*", "@fern-api/core": "workspace:*", "@fern-api/core-utils": "workspace:*", - "@fern-api/fdr-sdk": "1.2.4-f661387fb2", + "@fern-api/fdr-sdk": "1.2.16-3b754a56be", "@fern-api/fs-utils": "workspace:*", "@fern-api/ir-generator": "workspace:*", "@fern-api/ir-sdk": "workspace:*", diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/fixtures/audience-filtered-missing-type/generators.yml b/packages/cli/register/src/ir-to-fdr-converter/__test__/fixtures/audience-filtered-missing-type/generators.yml new file mode 100644 index 000000000000..bc9a333a3500 --- /dev/null +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/fixtures/audience-filtered-missing-type/generators.yml @@ -0,0 +1,3 @@ +api: + specs: + - openapi: openapi.yml diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/fixtures/audience-filtered-missing-type/openapi.yml b/packages/cli/register/src/ir-to-fdr-converter/__test__/fixtures/audience-filtered-missing-type/openapi.yml new file mode 100644 index 000000000000..c4850f84503b --- /dev/null +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/fixtures/audience-filtered-missing-type/openapi.yml @@ -0,0 +1,71 @@ +openapi: 3.0.3 +info: + title: Audience Filtered Missing Type Test + version: 1.0.0 +paths: + /v2/chat: + post: + operationId: chat + x-fern-audiences: + - public + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + messages: + x-fern-audiences: + - public + type: array + items: + $ref: "#/components/schemas/ChatMessageV2" + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + text: + type: string +components: + schemas: + ChatMessageV2: + title: ChatMessageV2 + oneOf: + - $ref: "#/components/schemas/UserMessage" + - $ref: "#/components/schemas/AssistantMessage" + discriminator: + propertyName: role + mapping: + USER: "#/components/schemas/UserMessage" + ASSISTANT: "#/components/schemas/AssistantMessage" + UserMessage: + title: UserMessage + type: object + properties: + role: + type: string + enum: + - USER + content: + type: string + required: + - role + - content + AssistantMessage: + title: AssistantMessage + type: object + properties: + role: + type: string + enum: + - ASSISTANT + content: + type: string + required: + - role + - content diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/openapi-from-flag.test.ts b/packages/cli/register/src/ir-to-fdr-converter/__test__/openapi-from-flag.test.ts index 67de05d73779..dc14b5c300f3 100644 --- a/packages/cli/register/src/ir-to-fdr-converter/__test__/openapi-from-flag.test.ts +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/openapi-from-flag.test.ts @@ -3366,4 +3366,64 @@ describe("OpenAPI v3 Parser Pipeline (--from-openapi flag)", () => { expect(ir).toBeDefined(); expect(ir.services).toBeDefined(); }); + + it("should handle audience-filtered types referenced by endpoints without crashing", async () => { + const context = createMockTaskContext(); + const workspace = await loadAPIWorkspace({ + absolutePathToWorkspace: join( + AbsoluteFilePath.of(__dirname), + RelativeFilePath.of("fixtures/audience-filtered-missing-type") + ), + context, + cliVersion: "0.0.0", + workspaceName: "audience-filtered-missing-type" + }); + + expect(workspace.didSucceed).toBe(true); + assert(workspace.didSucceed); + + if (!(workspace.workspace instanceof OSSWorkspace)) { + throw new Error( + `Expected OSSWorkspace for OpenAPI processing, got ${workspace.workspace.constructor.name}` + ); + } + + // Use selective audiences so that untagged schemas are excluded from IR. + // Previously, this caused "Failed to find ChatMessageV2" during FDR + // example generation because the type was dropped by audience filtering + // while endpoint references to it remained. + const ir = await workspace.workspace.getIntermediateRepresentation({ + context, + audiences: { type: "select", audiences: ["public"] }, + enableUniqueErrorsPerEndpoint: true, + generateV1Examples: false, + logWarnings: false + }); + + expect(ir).toBeDefined(); + expect(ir.services).toBeDefined(); + + const fdrApiDefinition = await convertIrToFdrApi({ + ir, + snippetsConfig: { + typescriptSdk: undefined, + pythonSdk: undefined, + javaSdk: undefined, + rubySdk: undefined, + goSdk: undefined, + csharpSdk: undefined, + phpSdk: undefined, + swiftSdk: undefined, + rustSdk: undefined + }, + playgroundConfig: { + oauth: true + }, + context + }); + + expect(fdrApiDefinition).toBeDefined(); + expect(fdrApiDefinition.types).toBeDefined(); + expect(fdrApiDefinition.rootPackage).toBeDefined(); + }); }); diff --git a/packages/cli/workspace/browser-compatible-fern-workspace/package.json b/packages/cli/workspace/browser-compatible-fern-workspace/package.json index 07c4421ae3a1..f79c7d08c63c 100644 --- a/packages/cli/workspace/browser-compatible-fern-workspace/package.json +++ b/packages/cli/workspace/browser-compatible-fern-workspace/package.json @@ -37,7 +37,7 @@ "@fern-api/asyncapi-to-ir": "workspace:*", "@fern-api/configuration": "workspace:*", "@fern-api/core-utils": "workspace:*", - "@fern-api/fdr-sdk": "1.2.4-f661387fb2", + "@fern-api/fdr-sdk": "1.2.16-3b754a56be", "@fern-api/ir-sdk": "workspace:*", "@fern-api/openapi-ir": "workspace:*", "@fern-api/openapi-ir-parser": "workspace:*", diff --git a/packages/cli/workspace/lazy-fern-workspace/package.json b/packages/cli/workspace/lazy-fern-workspace/package.json index 41823d80134a..c118f8c3c3dc 100644 --- a/packages/cli/workspace/lazy-fern-workspace/package.json +++ b/packages/cli/workspace/lazy-fern-workspace/package.json @@ -41,7 +41,7 @@ "@fern-api/conjure-to-fern": "workspace:*", "@fern-api/core": "workspace:*", "@fern-api/core-utils": "workspace:*", - "@fern-api/fdr-sdk": "1.2.4-f661387fb2", + "@fern-api/fdr-sdk": "1.2.16-3b754a56be", "@fern-api/fern-definition-schema": "workspace:*", "@fern-api/fs-utils": "workspace:*", "@fern-api/graphql-to-fdr": "workspace:*", diff --git a/packages/cli/workspace/lazy-fern-workspace/src/utils/Result.ts b/packages/cli/workspace/lazy-fern-workspace/src/utils/Result.ts index 140f0a03737e..8b6076777aa9 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/utils/Result.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/utils/Result.ts @@ -23,6 +23,7 @@ export declare namespace WorkspaceLoader { | FileReadFailure | FileParseFailure | MissingFileFailure + | AbsoluteFilepathFailure | StructureValidationFailure | DependencyFailure | JsonSchemaValidationFailure; @@ -41,6 +42,11 @@ export declare namespace WorkspaceLoader { type: WorkspaceLoaderFailureType.FILE_MISSING; } + export interface AbsoluteFilepathFailure { + type: WorkspaceLoaderFailureType.ABSOLUTE_FILEPATH; + filepath: string; + } + export interface MisconfiguredDirectoryFailure { type: WorkspaceLoaderFailureType.MISCONFIGURED_DIRECTORY; } @@ -87,6 +93,7 @@ export enum WorkspaceLoaderFailureType { FILE_READ = "FILE_READ", FILE_PARSE = "FILE_PARSE", FILE_MISSING = "FILE_MISSING", + ABSOLUTE_FILEPATH = "ABSOLUTE_FILEPATH", STRUCTURE_VALIDATION = "STRUCTURE_VALIDATION", JSONSCHEMA_VALIDATION = "JSONSCHEMA_VALIDATION", DEPENDENCY_NOT_LISTED = "DEPENDENCY_NOT_LISTED", diff --git a/packages/cli/workspace/lazy-fern-workspace/src/utils/handleFailedWorkspaceParserResult.ts b/packages/cli/workspace/lazy-fern-workspace/src/utils/handleFailedWorkspaceParserResult.ts index 29d7207d2cb0..ac5ba7766cda 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/utils/handleFailedWorkspaceParserResult.ts +++ b/packages/cli/workspace/lazy-fern-workspace/src/utils/handleFailedWorkspaceParserResult.ts @@ -52,6 +52,11 @@ function handleWorkspaceParserFailureForFile({ case WorkspaceLoaderFailureType.FILE_MISSING: logger.error("Missing file: " + relativeFilepath); break; + case WorkspaceLoaderFailureType.ABSOLUTE_FILEPATH: + logger.error( + `OpenAPI spec paths must be relative to the Fern workspace. Found absolute path: ${failure.filepath}` + ); + break; case WorkspaceLoaderFailureType.FILE_PARSE: if (failure.error instanceof YAMLException) { logger.error( diff --git a/packages/cli/workspace/loader/src/__test__/loadWorkspace.test.ts b/packages/cli/workspace/loader/src/__test__/loadWorkspace.test.ts index f7473663c324..90ea3ae83190 100644 --- a/packages/cli/workspace/loader/src/__test__/loadWorkspace.test.ts +++ b/packages/cli/workspace/loader/src/__test__/loadWorkspace.test.ts @@ -6,7 +6,7 @@ import { createMockTaskContext } from "@fern-api/task-context"; import assert from "assert"; import { handleFailedWorkspaceParserResult } from "../handleFailedWorkspaceParserResult.js"; -import { loadAPIWorkspace } from "../loadAPIWorkspace.js"; +import { loadAPIWorkspace, loadSingleNamespaceAPIWorkspace } from "../loadAPIWorkspace.js"; function createCapturingLogger(): Logger & { errors: string[]; debugs: string[] } { const errors: string[] = []; @@ -71,6 +71,38 @@ describe("loadWorkspace", () => { expect(workspace.didSucceed).toBe(true); assert(workspace.didSucceed); }); + + it("rejects open api with absolute spec path", async () => { + const absolutePathToFixtures = join(AbsoluteFilePath.of(__dirname), RelativeFilePath.of("fixtures")); + const absolutePathToOpenApi = join(absolutePathToFixtures, RelativeFilePath.of("openapi.yml")); + + const result = await loadSingleNamespaceAPIWorkspace({ + absolutePathToWorkspace: absolutePathToFixtures, + namespace: undefined, + definitions: [ + { + schema: { + type: "oss", + path: absolutePathToOpenApi + }, + origin: undefined, + overrides: undefined, + overlays: undefined, + audiences: [], + settings: undefined + } + ] + }); + + expect(Array.isArray(result)).toBe(false); + assert(!Array.isArray(result)); + expect(result.didSucceed).toBe(false); + assert(!result.didSucceed); + expect(result.failures[RelativeFilePath.of("generators.yml")]).toEqual({ + type: WorkspaceLoaderFailureType.ABSOLUTE_FILEPATH, + filepath: absolutePathToOpenApi + }); + }); }); describe("loadWorkspace MISCONFIGURED_DIRECTORY", () => { diff --git a/packages/cli/workspace/loader/src/handleFailedWorkspaceParserResult.ts b/packages/cli/workspace/loader/src/handleFailedWorkspaceParserResult.ts index 8a8df15174d6..1c9f0a3e8f99 100644 --- a/packages/cli/workspace/loader/src/handleFailedWorkspaceParserResult.ts +++ b/packages/cli/workspace/loader/src/handleFailedWorkspaceParserResult.ts @@ -51,6 +51,11 @@ function handleWorkspaceParserFailureForFile({ case WorkspaceLoaderFailureType.FILE_MISSING: logger.error("Missing file: " + relativeFilepath); break; + case WorkspaceLoaderFailureType.ABSOLUTE_FILEPATH: + logger.error( + `OpenAPI spec paths must be relative to the Fern workspace. Found absolute path: ${failure.filepath}` + ); + break; case WorkspaceLoaderFailureType.FILE_PARSE: if (failure.error instanceof YAMLException) { logger.error( diff --git a/packages/cli/workspace/loader/src/loadAPIWorkspace.ts b/packages/cli/workspace/loader/src/loadAPIWorkspace.ts index e10ef1cc2b11..ae1d23adf114 100644 --- a/packages/cli/workspace/loader/src/loadAPIWorkspace.ts +++ b/packages/cli/workspace/loader/src/loadAPIWorkspace.ts @@ -15,6 +15,7 @@ import { WorkspaceLoaderFailureType } from "@fern-api/lazy-fern-workspace"; import { TaskContext } from "@fern-api/task-context"; +import path from "path"; import { loadAPIChangelog } from "./loadAPIChangelog.js"; export async function loadSingleNamespaceAPIWorkspace({ @@ -148,12 +149,25 @@ export async function loadSingleNamespaceAPIWorkspace({ continue; } - const absoluteFilepath = join(absolutePathToWorkspace, RelativeFilePath.of(definition.schema.path)); + if (path.isAbsolute(definition.schema.path)) { + return { + didSucceed: false, + failures: { + [RelativeFilePath.of(GENERATORS_CONFIGURATION_FILENAME)]: { + type: WorkspaceLoaderFailureType.ABSOLUTE_FILEPATH, + filepath: definition.schema.path + } + } + }; + } + + const relativeFilepath = RelativeFilePath.of(definition.schema.path); + const absoluteFilepath = join(absolutePathToWorkspace, relativeFilepath); if (!(await doesPathExist(absoluteFilepath))) { return { didSucceed: false, failures: { - [RelativeFilePath.of(definition.schema.path)]: { + [relativeFilepath]: { type: WorkspaceLoaderFailureType.FILE_MISSING } } diff --git a/packages/cli/yaml/docs-validator/package.json b/packages/cli/yaml/docs-validator/package.json index dbb370058bd8..7c0dc444a946 100644 --- a/packages/cli/yaml/docs-validator/package.json +++ b/packages/cli/yaml/docs-validator/package.json @@ -40,7 +40,7 @@ "@fern-api/core-utils": "workspace:*", "@fern-api/docs-markdown-utils": "workspace:*", "@fern-api/docs-resolver": "workspace:*", - "@fern-api/fdr-sdk": "1.2.4-f661387fb2", + "@fern-api/fdr-sdk": "1.2.16-3b754a56be", "@fern-api/fern-definition-schema": "workspace:*", "@fern-api/fs-utils": "workspace:*", "@fern-api/ir-generator": "workspace:*", diff --git a/packages/core/package.json b/packages/core/package.json index c1f6b52795b7..2b176bcdcdfc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,7 +32,7 @@ "test:update": "vitest --passWithNoTests --run -u" }, "dependencies": { - "@fern-api/fdr-sdk": "1.2.4-f661387fb2", + "@fern-api/fdr-sdk": "1.2.16-3b754a56be", "@fern-api/venus-api-sdk": "catalog:", "@fern-fern/fdr-test-sdk": "catalog:", "@fern-fern/fiddle-sdk": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 127a73ef4164..badfdd36e8f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,8 +40,8 @@ catalogs: specifier: 0.9.35 version: 0.9.35 '@fern-api/venus-api-sdk': - specifier: 4.0.0 - version: 4.0.0 + specifier: 0.22.34 + version: 0.22.34 '@fern-fern/docs-config': specifier: 0.0.80 version: 0.0.80 @@ -436,8 +436,8 @@ catalogs: specifier: ^3.0.0 version: 3.0.0 open: - specifier: ^8.4.0 - version: 8.4.2 + specifier: ^11.0.0 + version: 11.0.0 ora: specifier: ^7.0.1 version: 7.0.1 @@ -593,7 +593,7 @@ overrides: minimatch: '>=10.2.3' qs: 6.15.0 url-join: ^4.0.1 - '@fern-api/fdr-sdk': 1.2.4-f661387fb2 + '@fern-api/fdr-sdk': 1.2.16-3b754a56be form-data: ^4.0.4 '@fern-api/ui-core-utils': 0.145.12-b50d999d1 vite: ^7.3.2 @@ -3873,8 +3873,8 @@ importers: packages/cli/api-importers/graphql: dependencies: '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -4249,8 +4249,8 @@ importers: specifier: workspace:* version: link:../../configuration '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/ir-generator': specifier: workspace:* version: link:../../generation/ir-generator @@ -4289,7 +4289,7 @@ importers: version: link:../task-context '@fern-api/venus-api-sdk': specifier: 'catalog:' - version: 4.0.0 + version: 0.22.34 jsonwebtoken: specifier: 'catalog:' version: 9.0.3 @@ -4376,8 +4376,8 @@ importers: specifier: workspace:* version: link:../yaml/docs-validator '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-formatter': specifier: workspace:* version: link:../fern-definition/formatter @@ -4473,7 +4473,7 @@ importers: version: link:../task-context '@fern-api/venus-api-sdk': specifier: 'catalog:' - version: 4.0.0 + version: 0.22.34 '@fern-api/workspace-loader': specifier: workspace:* version: link:../workspace/loader @@ -4822,8 +4822,8 @@ importers: specifier: workspace:* version: link:../yaml/docs-validator '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../fern-definition/schema @@ -4889,7 +4889,7 @@ importers: version: link:../task-context '@fern-api/venus-api-sdk': specifier: 'catalog:' - version: 4.0.0 + version: 0.22.34 '@fern-api/workspace-loader': specifier: workspace:* version: link:../workspace/loader @@ -5024,8 +5024,8 @@ importers: specifier: workspace:* version: link:../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../fern-definition/schema @@ -5061,8 +5061,8 @@ importers: specifier: workspace:* version: link:../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5140,8 +5140,8 @@ importers: specifier: workspace:* version: link:../../configuration '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -5180,8 +5180,8 @@ importers: specifier: workspace:* version: link:../commons '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -5220,8 +5220,8 @@ importers: specifier: workspace:* version: link:../commons '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../commons/fs-utils @@ -5287,8 +5287,8 @@ importers: packages/cli/docs-markdown-utils: dependencies: '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5378,8 +5378,8 @@ importers: specifier: workspace:* version: link:../docs-resolver '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5493,8 +5493,8 @@ importers: specifier: workspace:* version: link:../docs-markdown-utils '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -5596,8 +5596,8 @@ importers: specifier: workspace:* version: link:../configuration '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -6166,7 +6166,7 @@ importers: version: link:../../../../../generators/typescript-v2/dynamic-snippets '@fern-api/venus-api-sdk': specifier: 'catalog:' - version: 4.0.0 + version: 0.22.34 '@fern-api/workspace-loader': specifier: workspace:* version: link:../../../workspace/loader @@ -6301,8 +6301,8 @@ importers: specifier: 'catalog:' version: 0.0.6-2ee1b7e28 '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../../../commons/fs-utils @@ -6338,7 +6338,7 @@ importers: version: link:../../../task-context '@fern-api/venus-api-sdk': specifier: 'catalog:' - version: 4.0.0 + version: 0.22.34 '@fern-api/workspace-loader': specifier: workspace:* version: link:../../../workspace/loader @@ -6523,8 +6523,8 @@ importers: specifier: workspace:* version: link:../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -6601,7 +6601,7 @@ importers: version: 9.3.8(@types/node@22.19.17) open: specifier: 'catalog:' - version: 8.4.2 + version: 11.0.0 qs: specifier: 6.15.0 version: 6.15.0 @@ -6766,8 +6766,8 @@ importers: specifier: workspace:* version: link:../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fs-utils': specifier: workspace:* version: link:../../commons/fs-utils @@ -6962,8 +6962,8 @@ importers: specifier: workspace:* version: link:../../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/ir-sdk': specifier: workspace:* version: link:../../../ir-sdk @@ -7047,8 +7047,8 @@ importers: specifier: workspace:* version: link:../../../commons/core-utils '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../../fern-definition/schema @@ -7287,8 +7287,8 @@ importers: specifier: workspace:* version: link:../../docs-resolver '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/fern-definition-schema': specifier: workspace:* version: link:../../fern-definition/schema @@ -7932,11 +7932,11 @@ importers: packages/core: dependencies: '@fern-api/fdr-sdk': - specifier: 1.2.4-f661387fb2 - version: 1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3) + specifier: 1.2.16-3b754a56be + version: 1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3) '@fern-api/venus-api-sdk': specifier: 'catalog:' - version: 4.0.0 + version: 0.22.34 '@fern-fern/fdr-test-sdk': specifier: 'catalog:' version: 0.0.5297 @@ -9489,8 +9489,8 @@ packages: resolution: {integrity: sha512-3qhAAuc4ZJWLaFtyZzaYXfF9OQ5iviNrvLDXtjKScKUNS134fR3v3c3xedCidTq5KedapuBECziUaOmmd6KXVA==} engines: {node: '>=18.0.0'} - '@fern-api/fdr-sdk@1.2.4-f661387fb2': - resolution: {integrity: sha512-wlk1lTCIZ7biND4vQf8jvhUw9P/rBQ5pXASCrumv8R96up0B3DY6yiY1C4VmFyHmp/kPhcjzc5T9TvHZZxFdrA==} + '@fern-api/fdr-sdk@1.2.16-3b754a56be': + resolution: {integrity: sha512-0qWBYHUv2uS0JQ33t1ZhslTeJXSxR92lWJxNrj7t0PZGo63GgSilpu0VLYJDCBl3EH1jVoctdL/NfzyzdKBBWw==} '@fern-api/generator-cli@0.9.35': resolution: {integrity: sha512-84+K5K4jquuqpMo3p2NDk/OJuiyZtRcQW5tlEYH3R9y7G5wX71qwAWM21qWqu6+vYfN1oZ+4fUcqgqp6tOhxVA==} @@ -9504,8 +9504,8 @@ packages: '@fern-api/ui-core-utils@0.145.12-b50d999d1': resolution: {integrity: sha512-S1sE+Fs6kc/7F1Gzwsz7sZlAhMLIsaMeVDQ68038YXfzsLhrsg3DEVPvil5T3KxH/qV80Uso25q1D1W/LwXo6Q==} - '@fern-api/venus-api-sdk@4.0.0': - resolution: {integrity: sha512-cyy0be2/dA15UZOVfU6/xFsgRjXB9cq0WPERjCH5z9alPlWWUDK3NGPU7ayQK6rShO7K8DYORoFoAFME20W0BA==} + '@fern-api/venus-api-sdk@0.22.34': + resolution: {integrity: sha512-21J/+zvL/v2LlCsVcBu7uM6k0Ej7Tz+2Tg6qZkUNH5TS0wmU6CyQ3lURE9jwiDRnw/bpNNkVXGuu9R7Vmvyi6Q==} engines: {node: '>=18.0.0'} '@fern-fern/docs-config@0.0.80': @@ -11407,6 +11407,10 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -11809,6 +11813,14 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -11816,9 +11828,9 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} - define-lazy-prop@2.0.0: - resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} - engines: {node: '>=8'} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} @@ -12732,9 +12744,9 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} - is-docker@2.2.1: - resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} - engines: {node: '>=8'} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true is-extendable@0.1.1: @@ -12756,6 +12768,15 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-interactive@1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} @@ -12823,9 +12844,9 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} - is-wsl@2.2.0: - resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} - engines: {node: '>=8'} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -13528,9 +13549,9 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - open@8.4.2: - resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} - engines: {node: '>=12'} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} @@ -13819,6 +13840,10 @@ packages: rxjs: optional: true + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -14113,6 +14138,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + run-async@3.0.0: resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} engines: {node: '>=0.12.0'} @@ -15075,6 +15104,10 @@ packages: utf-8-validate: optional: true + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + xdg-basedir@5.1.0: resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==} engines: {node: '>=12'} @@ -16406,7 +16439,7 @@ snapshots: '@fern-api/fai-sdk@0.0.6-2ee1b7e28': {} - '@fern-api/fdr-sdk@1.2.4-f661387fb2(@opentelemetry/api@1.9.1)(typescript@5.9.3)': + '@fern-api/fdr-sdk@1.2.16-3b754a56be(@opentelemetry/api@1.9.1)(typescript@5.9.3)': dependencies: '@fern-api/ui-core-utils': 0.145.12-b50d999d1 '@orpc/client': 1.13.9(@opentelemetry/api@1.9.1) @@ -16461,7 +16494,7 @@ snapshots: title: 3.5.3 ua-parser-js: 1.0.41 - '@fern-api/venus-api-sdk@4.0.0': {} + '@fern-api/venus-api-sdk@0.22.34': {} '@fern-fern/docs-config@0.0.80': {} @@ -18424,6 +18457,10 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 @@ -18837,6 +18874,13 @@ snapshots: deep-is@0.1.4: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + defaults@1.0.4: dependencies: clone: 1.0.4 @@ -18847,7 +18891,7 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - define-lazy-prop@2.0.0: {} + define-lazy-prop@3.0.0: {} defu@6.1.7: {} @@ -19944,7 +19988,7 @@ snapshots: is-decimal@2.0.1: {} - is-docker@2.2.1: {} + is-docker@3.0.0: {} is-extendable@0.1.1: {} @@ -19958,6 +20002,12 @@ snapshots: is-hexadecimal@2.0.1: {} + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-interactive@1.0.0: {} is-interactive@2.0.0: {} @@ -19994,9 +20044,9 @@ snapshots: is-unicode-supported@2.1.0: {} - is-wsl@2.2.0: + is-wsl@3.1.1: dependencies: - is-docker: 2.2.1 + is-inside-container: 1.0.0 isarray@1.0.0: {} @@ -20988,11 +21038,14 @@ snapshots: dependencies: mimic-fn: 2.1.0 - open@8.4.2: + open@11.0.0: dependencies: - define-lazy-prop: 2.0.0 - is-docker: 2.2.1 - is-wsl: 2.2.0 + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 openapi-types@12.1.3: {} @@ -21281,6 +21334,8 @@ snapshots: optionalDependencies: rxjs: 7.8.2 + powershell-utils@0.1.0: {} + prelude-ls@1.2.1: {} prettier@2.8.8: {} @@ -21706,6 +21761,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.60.4 fsevents: 2.3.3 + run-applescript@7.1.0: {} + run-async@3.0.0: {} run-parallel@1.2.0: @@ -22768,6 +22825,11 @@ snapshots: ws@8.20.1: {} + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + xdg-basedir@5.1.0: {} xml2js@0.6.2: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f112db8a83ef..d3369782a689 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -68,10 +68,10 @@ catalog: "@bufbuild/protobuf": ^2.2.5 "@bufbuild/protoplugin": 2.2.5 "@fern-api/fai-sdk": 0.0.6-2ee1b7e28 - "@fern-api/fdr-sdk": 1.2.4-f661387fb2 + "@fern-api/fdr-sdk": 1.2.16-3b754a56be "@fern-api/generator-cli": 0.9.35 "@fern-api/ui-core-utils": 0.129.4-b6c699ad2 - "@fern-api/venus-api-sdk": 4.0.0 + "@fern-api/venus-api-sdk": 0.22.34 "@fern-fern/docs-config": 0.0.80 "@fern-fern/fdr-test-sdk": ^0.0.5297 "@fern-fern/fiddle-sdk": 1.1.0 @@ -208,7 +208,7 @@ catalog: node-fetch: 2.7.0 number-to-words: 1.2.4 object-hash: ^3.0.0 - open: ^8.4.0 + open: ^11.0.0 ora: ^7.0.1 package-json-type: ^1.0.3 path-to-regexp: 6.3.0 @@ -270,7 +270,7 @@ overrides: minimatch: '>=10.2.3' qs: 6.15.0 url-join: ^4.0.1 - '@fern-api/fdr-sdk': 1.2.4-f661387fb2 + '@fern-api/fdr-sdk': 1.2.16-3b754a56be form-data: ^4.0.4 '@fern-api/ui-core-utils': 0.145.12-b50d999d1 vite: ^7.3.2 diff --git a/seed/cli/allof-inline/.github/workflows/ci.yml b/seed/cli/allof-inline/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/allof-inline/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/allof-inline/.github/workflows/release.yml b/seed/cli/allof-inline/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/allof-inline/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/allof-inline/Cargo.lock b/seed/cli/allof-inline/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/allof-inline/Cargo.lock +++ b/seed/cli/allof-inline/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/allof-inline/Cargo.toml b/seed/cli/allof-inline/Cargo.toml index 3173f130532a..6b45c0e2e02a 100644 --- a/seed/cli/allof-inline/Cargo.toml +++ b/seed/cli/allof-inline/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "allof-composition" +path = "cli/allof-composition/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/allof-inline/cli/allof-composition/main.rs b/seed/cli/allof-inline/cli/allof-composition/main.rs new file mode 100644 index 000000000000..e21b3eaf1a9e --- /dev/null +++ b/seed/cli/allof-inline/cli/allof-composition/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("allof-composition") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/allof-inline/cli/openapi-fixture/openapi0.json b/seed/cli/allof-inline/cli/allof-composition/openapi0.json similarity index 100% rename from seed/cli/allof-inline/cli/openapi-fixture/openapi0.json rename to seed/cli/allof-inline/cli/allof-composition/openapi0.json diff --git a/seed/cli/allof-inline/cli/openapi-fixture/main.rs b/seed/cli/allof-inline/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/allof-inline/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/allof-inline/dist-workspace.toml b/seed/cli/allof-inline/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/allof-inline/dist-workspace.toml +++ b/seed/cli/allof-inline/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/allof-inline/src/app.rs b/seed/cli/allof-inline/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/allof-inline/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/allof-inline/src/arg_source.rs b/seed/cli/allof-inline/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/allof-inline/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/seed/cli/allof-inline/src/auth/builder.rs b/seed/cli/allof-inline/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/allof-inline/src/auth/builder.rs +++ b/seed/cli/allof-inline/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/allof-inline/src/auth/mod.rs b/seed/cli/allof-inline/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/allof-inline/src/auth/mod.rs +++ b/seed/cli/allof-inline/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/allof-inline/src/auth/root_builder.rs b/seed/cli/allof-inline/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/allof-inline/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/allof-inline/src/binding.rs b/seed/cli/allof-inline/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/allof-inline/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/allof-inline/src/cli_args.rs b/seed/cli/allof-inline/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/allof-inline/src/cli_args.rs +++ b/seed/cli/allof-inline/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/allof-inline/src/completions.rs b/seed/cli/allof-inline/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/allof-inline/src/completions.rs +++ b/seed/cli/allof-inline/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/allof-inline/src/custom_commands.rs b/seed/cli/allof-inline/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/allof-inline/src/custom_commands.rs +++ b/seed/cli/allof-inline/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/allof-inline/src/early_intercept.rs b/seed/cli/allof-inline/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/allof-inline/src/early_intercept.rs +++ b/seed/cli/allof-inline/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/allof-inline/src/error.rs b/seed/cli/allof-inline/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/allof-inline/src/error.rs +++ b/seed/cli/allof-inline/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/allof-inline/src/formatter.rs b/seed/cli/allof-inline/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/allof-inline/src/formatter.rs +++ b/seed/cli/allof-inline/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/allof-inline/src/graphql/app.rs b/seed/cli/allof-inline/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/allof-inline/src/graphql/app.rs +++ b/seed/cli/allof-inline/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/allof-inline/src/graphql/binding.rs b/seed/cli/allof-inline/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/allof-inline/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/allof-inline/src/graphql/commands.rs b/seed/cli/allof-inline/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/allof-inline/src/graphql/commands.rs +++ b/seed/cli/allof-inline/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/allof-inline/src/graphql/mod.rs b/seed/cli/allof-inline/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/allof-inline/src/graphql/mod.rs +++ b/seed/cli/allof-inline/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/allof-inline/src/hooks.rs b/seed/cli/allof-inline/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/allof-inline/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/seed/cli/allof-inline/src/lib.rs b/seed/cli/allof-inline/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/allof-inline/src/lib.rs +++ b/seed/cli/allof-inline/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/allof-inline/src/logging.rs b/seed/cli/allof-inline/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/allof-inline/src/logging.rs +++ b/seed/cli/allof-inline/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/allof-inline/src/man.rs b/seed/cli/allof-inline/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/allof-inline/src/man.rs +++ b/seed/cli/allof-inline/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/allof-inline/src/openapi/__fixtures__/openapi.json b/seed/cli/allof-inline/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/allof-inline/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/allof-inline/src/openapi/app.rs b/seed/cli/allof-inline/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/allof-inline/src/openapi/app.rs +++ b/seed/cli/allof-inline/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/allof-inline/src/openapi/binding.rs b/seed/cli/allof-inline/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/allof-inline/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/allof-inline/src/openapi/commands.rs b/seed/cli/allof-inline/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/allof-inline/src/openapi/commands.rs +++ b/seed/cli/allof-inline/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/allof-inline/src/openapi/discovery.rs b/seed/cli/allof-inline/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/allof-inline/src/openapi/discovery.rs +++ b/seed/cli/allof-inline/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/allof-inline/src/openapi/executor.rs b/seed/cli/allof-inline/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/allof-inline/src/openapi/executor.rs +++ b/seed/cli/allof-inline/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/allof-inline/src/openapi/help.rs b/seed/cli/allof-inline/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/allof-inline/src/openapi/help.rs +++ b/seed/cli/allof-inline/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/allof-inline/src/openapi/mod.rs b/seed/cli/allof-inline/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/allof-inline/src/openapi/mod.rs +++ b/seed/cli/allof-inline/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/allof-inline/src/openapi/overlay.rs b/seed/cli/allof-inline/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/allof-inline/src/openapi/overlay.rs +++ b/seed/cli/allof-inline/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/allof-inline/src/openapi/parser.rs b/seed/cli/allof-inline/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/allof-inline/src/openapi/parser.rs +++ b/seed/cli/allof-inline/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/allof-inline/src/openapi/skill_emitter.rs b/seed/cli/allof-inline/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/allof-inline/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/allof-inline/src/stability.rs b/seed/cli/allof-inline/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/allof-inline/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/seed/cli/allof-inline/tests/auth_routing_wire.rs b/seed/cli/allof-inline/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/allof-inline/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/allof-inline/tests/common/mod.rs b/seed/cli/allof-inline/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/allof-inline/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/allof-inline/tests/lib_api.rs b/seed/cli/allof-inline/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/allof-inline/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/allof-inline/tests/openapi_streaming_wire.rs b/seed/cli/allof-inline/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/allof-inline/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/allof-inline/tests/tls_env_vars.rs b/seed/cli/allof-inline/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/allof-inline/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/allof-inline/tests/websocket_wire.rs b/seed/cli/allof-inline/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/allof-inline/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/allof-inline/tests/x_name_server_alias_wire.rs b/seed/cli/allof-inline/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/allof-inline/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/allof/.github/workflows/ci.yml b/seed/cli/allof/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/allof/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/allof/.github/workflows/release.yml b/seed/cli/allof/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/allof/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/allof/Cargo.lock b/seed/cli/allof/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/allof/Cargo.lock +++ b/seed/cli/allof/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/allof/Cargo.toml b/seed/cli/allof/Cargo.toml index 3173f130532a..6b45c0e2e02a 100644 --- a/seed/cli/allof/Cargo.toml +++ b/seed/cli/allof/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "allof-composition" +path = "cli/allof-composition/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/allof/cli/allof-composition/main.rs b/seed/cli/allof/cli/allof-composition/main.rs new file mode 100644 index 000000000000..e21b3eaf1a9e --- /dev/null +++ b/seed/cli/allof/cli/allof-composition/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("allof-composition") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/allof/cli/openapi-fixture/openapi0.json b/seed/cli/allof/cli/allof-composition/openapi0.json similarity index 100% rename from seed/cli/allof/cli/openapi-fixture/openapi0.json rename to seed/cli/allof/cli/allof-composition/openapi0.json diff --git a/seed/cli/allof/cli/openapi-fixture/main.rs b/seed/cli/allof/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/allof/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/allof/dist-workspace.toml b/seed/cli/allof/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/allof/dist-workspace.toml +++ b/seed/cli/allof/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/allof/src/app.rs b/seed/cli/allof/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/allof/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/allof/src/arg_source.rs b/seed/cli/allof/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/allof/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/seed/cli/allof/src/auth/builder.rs b/seed/cli/allof/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/allof/src/auth/builder.rs +++ b/seed/cli/allof/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/allof/src/auth/mod.rs b/seed/cli/allof/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/allof/src/auth/mod.rs +++ b/seed/cli/allof/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/allof/src/auth/root_builder.rs b/seed/cli/allof/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/allof/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/allof/src/binding.rs b/seed/cli/allof/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/allof/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/allof/src/cli_args.rs b/seed/cli/allof/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/allof/src/cli_args.rs +++ b/seed/cli/allof/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/allof/src/completions.rs b/seed/cli/allof/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/allof/src/completions.rs +++ b/seed/cli/allof/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/allof/src/custom_commands.rs b/seed/cli/allof/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/allof/src/custom_commands.rs +++ b/seed/cli/allof/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/allof/src/early_intercept.rs b/seed/cli/allof/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/allof/src/early_intercept.rs +++ b/seed/cli/allof/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/allof/src/error.rs b/seed/cli/allof/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/allof/src/error.rs +++ b/seed/cli/allof/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/allof/src/formatter.rs b/seed/cli/allof/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/allof/src/formatter.rs +++ b/seed/cli/allof/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/allof/src/graphql/app.rs b/seed/cli/allof/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/allof/src/graphql/app.rs +++ b/seed/cli/allof/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/allof/src/graphql/binding.rs b/seed/cli/allof/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/allof/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/allof/src/graphql/commands.rs b/seed/cli/allof/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/allof/src/graphql/commands.rs +++ b/seed/cli/allof/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/allof/src/graphql/mod.rs b/seed/cli/allof/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/allof/src/graphql/mod.rs +++ b/seed/cli/allof/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/allof/src/hooks.rs b/seed/cli/allof/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/allof/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/seed/cli/allof/src/lib.rs b/seed/cli/allof/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/allof/src/lib.rs +++ b/seed/cli/allof/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/allof/src/logging.rs b/seed/cli/allof/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/allof/src/logging.rs +++ b/seed/cli/allof/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/allof/src/man.rs b/seed/cli/allof/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/allof/src/man.rs +++ b/seed/cli/allof/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/allof/src/openapi/__fixtures__/openapi.json b/seed/cli/allof/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/allof/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/allof/src/openapi/app.rs b/seed/cli/allof/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/allof/src/openapi/app.rs +++ b/seed/cli/allof/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/allof/src/openapi/binding.rs b/seed/cli/allof/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/allof/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/allof/src/openapi/commands.rs b/seed/cli/allof/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/allof/src/openapi/commands.rs +++ b/seed/cli/allof/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/allof/src/openapi/discovery.rs b/seed/cli/allof/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/allof/src/openapi/discovery.rs +++ b/seed/cli/allof/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/allof/src/openapi/executor.rs b/seed/cli/allof/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/allof/src/openapi/executor.rs +++ b/seed/cli/allof/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/allof/src/openapi/help.rs b/seed/cli/allof/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/allof/src/openapi/help.rs +++ b/seed/cli/allof/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/allof/src/openapi/mod.rs b/seed/cli/allof/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/allof/src/openapi/mod.rs +++ b/seed/cli/allof/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/allof/src/openapi/overlay.rs b/seed/cli/allof/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/allof/src/openapi/overlay.rs +++ b/seed/cli/allof/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/allof/src/openapi/parser.rs b/seed/cli/allof/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/allof/src/openapi/parser.rs +++ b/seed/cli/allof/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/allof/src/openapi/skill_emitter.rs b/seed/cli/allof/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/allof/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/allof/src/stability.rs b/seed/cli/allof/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/allof/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/seed/cli/allof/tests/auth_routing_wire.rs b/seed/cli/allof/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/allof/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/allof/tests/common/mod.rs b/seed/cli/allof/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/allof/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/allof/tests/lib_api.rs b/seed/cli/allof/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/allof/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/allof/tests/openapi_streaming_wire.rs b/seed/cli/allof/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/allof/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/allof/tests/tls_env_vars.rs b/seed/cli/allof/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/allof/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/allof/tests/websocket_wire.rs b/seed/cli/allof/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/allof/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/allof/tests/x_name_server_alias_wire.rs b/seed/cli/allof/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/allof/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/api-wide-base-path-with-default/.github/workflows/ci.yml b/seed/cli/api-wide-base-path-with-default/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/api-wide-base-path-with-default/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/api-wide-base-path-with-default/.github/workflows/release.yml b/seed/cli/api-wide-base-path-with-default/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/api-wide-base-path-with-default/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/api-wide-base-path-with-default/Cargo.lock b/seed/cli/api-wide-base-path-with-default/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/api-wide-base-path-with-default/Cargo.lock +++ b/seed/cli/api-wide-base-path-with-default/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/api-wide-base-path-with-default/Cargo.toml b/seed/cli/api-wide-base-path-with-default/Cargo.toml index 3173f130532a..30ae02bd91eb 100644 --- a/seed/cli/api-wide-base-path-with-default/Cargo.toml +++ b/seed/cli/api-wide-base-path-with-default/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "api-wide-base-path-with-default" +path = "cli/api-wide-base-path-with-default/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/api-wide-base-path-with-default/cli/api-wide-base-path-with-default/main.rs b/seed/cli/api-wide-base-path-with-default/cli/api-wide-base-path-with-default/main.rs new file mode 100644 index 000000000000..ca3b1ca48949 --- /dev/null +++ b/seed/cli/api-wide-base-path-with-default/cli/api-wide-base-path-with-default/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("api-wide-base-path-with-default") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/api-wide-base-path-with-default/cli/openapi-fixture/openapi0.json b/seed/cli/api-wide-base-path-with-default/cli/api-wide-base-path-with-default/openapi0.json similarity index 100% rename from seed/cli/api-wide-base-path-with-default/cli/openapi-fixture/openapi0.json rename to seed/cli/api-wide-base-path-with-default/cli/api-wide-base-path-with-default/openapi0.json diff --git a/seed/cli/api-wide-base-path-with-default/cli/openapi-fixture/main.rs b/seed/cli/api-wide-base-path-with-default/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/api-wide-base-path-with-default/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/api-wide-base-path-with-default/dist-workspace.toml b/seed/cli/api-wide-base-path-with-default/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/api-wide-base-path-with-default/dist-workspace.toml +++ b/seed/cli/api-wide-base-path-with-default/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/api-wide-base-path-with-default/src/app.rs b/seed/cli/api-wide-base-path-with-default/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/api-wide-base-path-with-default/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/api-wide-base-path-with-default/src/arg_source.rs b/seed/cli/api-wide-base-path-with-default/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/api-wide-base-path-with-default/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/seed/cli/api-wide-base-path-with-default/src/auth/builder.rs b/seed/cli/api-wide-base-path-with-default/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/api-wide-base-path-with-default/src/auth/builder.rs +++ b/seed/cli/api-wide-base-path-with-default/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/api-wide-base-path-with-default/src/auth/mod.rs b/seed/cli/api-wide-base-path-with-default/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/api-wide-base-path-with-default/src/auth/mod.rs +++ b/seed/cli/api-wide-base-path-with-default/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/api-wide-base-path-with-default/src/auth/root_builder.rs b/seed/cli/api-wide-base-path-with-default/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/api-wide-base-path-with-default/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/api-wide-base-path-with-default/src/binding.rs b/seed/cli/api-wide-base-path-with-default/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/api-wide-base-path-with-default/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/api-wide-base-path-with-default/src/cli_args.rs b/seed/cli/api-wide-base-path-with-default/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/api-wide-base-path-with-default/src/cli_args.rs +++ b/seed/cli/api-wide-base-path-with-default/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/api-wide-base-path-with-default/src/completions.rs b/seed/cli/api-wide-base-path-with-default/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/api-wide-base-path-with-default/src/completions.rs +++ b/seed/cli/api-wide-base-path-with-default/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/api-wide-base-path-with-default/src/custom_commands.rs b/seed/cli/api-wide-base-path-with-default/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/api-wide-base-path-with-default/src/custom_commands.rs +++ b/seed/cli/api-wide-base-path-with-default/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/api-wide-base-path-with-default/src/early_intercept.rs b/seed/cli/api-wide-base-path-with-default/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/api-wide-base-path-with-default/src/early_intercept.rs +++ b/seed/cli/api-wide-base-path-with-default/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/api-wide-base-path-with-default/src/error.rs b/seed/cli/api-wide-base-path-with-default/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/api-wide-base-path-with-default/src/error.rs +++ b/seed/cli/api-wide-base-path-with-default/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/api-wide-base-path-with-default/src/formatter.rs b/seed/cli/api-wide-base-path-with-default/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/api-wide-base-path-with-default/src/formatter.rs +++ b/seed/cli/api-wide-base-path-with-default/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/api-wide-base-path-with-default/src/graphql/app.rs b/seed/cli/api-wide-base-path-with-default/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/api-wide-base-path-with-default/src/graphql/app.rs +++ b/seed/cli/api-wide-base-path-with-default/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/api-wide-base-path-with-default/src/graphql/binding.rs b/seed/cli/api-wide-base-path-with-default/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/api-wide-base-path-with-default/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/api-wide-base-path-with-default/src/graphql/commands.rs b/seed/cli/api-wide-base-path-with-default/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/api-wide-base-path-with-default/src/graphql/commands.rs +++ b/seed/cli/api-wide-base-path-with-default/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/api-wide-base-path-with-default/src/graphql/mod.rs b/seed/cli/api-wide-base-path-with-default/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/api-wide-base-path-with-default/src/graphql/mod.rs +++ b/seed/cli/api-wide-base-path-with-default/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/api-wide-base-path-with-default/src/hooks.rs b/seed/cli/api-wide-base-path-with-default/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/api-wide-base-path-with-default/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/seed/cli/api-wide-base-path-with-default/src/lib.rs b/seed/cli/api-wide-base-path-with-default/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/api-wide-base-path-with-default/src/lib.rs +++ b/seed/cli/api-wide-base-path-with-default/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/api-wide-base-path-with-default/src/logging.rs b/seed/cli/api-wide-base-path-with-default/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/api-wide-base-path-with-default/src/logging.rs +++ b/seed/cli/api-wide-base-path-with-default/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/api-wide-base-path-with-default/src/man.rs b/seed/cli/api-wide-base-path-with-default/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/api-wide-base-path-with-default/src/man.rs +++ b/seed/cli/api-wide-base-path-with-default/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/api-wide-base-path-with-default/src/openapi/__fixtures__/openapi.json b/seed/cli/api-wide-base-path-with-default/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/api-wide-base-path-with-default/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/api-wide-base-path-with-default/src/openapi/app.rs b/seed/cli/api-wide-base-path-with-default/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/api-wide-base-path-with-default/src/openapi/app.rs +++ b/seed/cli/api-wide-base-path-with-default/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/api-wide-base-path-with-default/src/openapi/binding.rs b/seed/cli/api-wide-base-path-with-default/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/api-wide-base-path-with-default/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/api-wide-base-path-with-default/src/openapi/commands.rs b/seed/cli/api-wide-base-path-with-default/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/api-wide-base-path-with-default/src/openapi/commands.rs +++ b/seed/cli/api-wide-base-path-with-default/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/api-wide-base-path-with-default/src/openapi/discovery.rs b/seed/cli/api-wide-base-path-with-default/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/api-wide-base-path-with-default/src/openapi/discovery.rs +++ b/seed/cli/api-wide-base-path-with-default/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/api-wide-base-path-with-default/src/openapi/executor.rs b/seed/cli/api-wide-base-path-with-default/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/api-wide-base-path-with-default/src/openapi/executor.rs +++ b/seed/cli/api-wide-base-path-with-default/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/api-wide-base-path-with-default/src/openapi/help.rs b/seed/cli/api-wide-base-path-with-default/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/api-wide-base-path-with-default/src/openapi/help.rs +++ b/seed/cli/api-wide-base-path-with-default/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/api-wide-base-path-with-default/src/openapi/mod.rs b/seed/cli/api-wide-base-path-with-default/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/api-wide-base-path-with-default/src/openapi/mod.rs +++ b/seed/cli/api-wide-base-path-with-default/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/api-wide-base-path-with-default/src/openapi/overlay.rs b/seed/cli/api-wide-base-path-with-default/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/api-wide-base-path-with-default/src/openapi/overlay.rs +++ b/seed/cli/api-wide-base-path-with-default/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/api-wide-base-path-with-default/src/openapi/parser.rs b/seed/cli/api-wide-base-path-with-default/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/api-wide-base-path-with-default/src/openapi/parser.rs +++ b/seed/cli/api-wide-base-path-with-default/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/api-wide-base-path-with-default/src/openapi/skill_emitter.rs b/seed/cli/api-wide-base-path-with-default/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/api-wide-base-path-with-default/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/api-wide-base-path-with-default/src/stability.rs b/seed/cli/api-wide-base-path-with-default/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/api-wide-base-path-with-default/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/seed/cli/api-wide-base-path-with-default/tests/auth_routing_wire.rs b/seed/cli/api-wide-base-path-with-default/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/api-wide-base-path-with-default/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/api-wide-base-path-with-default/tests/common/mod.rs b/seed/cli/api-wide-base-path-with-default/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/api-wide-base-path-with-default/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/api-wide-base-path-with-default/tests/lib_api.rs b/seed/cli/api-wide-base-path-with-default/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/api-wide-base-path-with-default/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/api-wide-base-path-with-default/tests/openapi_streaming_wire.rs b/seed/cli/api-wide-base-path-with-default/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/api-wide-base-path-with-default/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/api-wide-base-path-with-default/tests/tls_env_vars.rs b/seed/cli/api-wide-base-path-with-default/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/api-wide-base-path-with-default/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/api-wide-base-path-with-default/tests/websocket_wire.rs b/seed/cli/api-wide-base-path-with-default/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/api-wide-base-path-with-default/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/api-wide-base-path-with-default/tests/x_name_server_alias_wire.rs b/seed/cli/api-wide-base-path-with-default/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/api-wide-base-path-with-default/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/.github/workflows/ci.yml b/seed/cli/cli-multi-spec-namespaced/no-custom-config/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/.github/workflows/release.yml b/seed/cli/cli-multi-spec-namespaced/no-custom-config/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/Cargo.lock b/seed/cli/cli-multi-spec-namespaced/no-custom-config/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/Cargo.lock +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/Cargo.toml b/seed/cli/cli-multi-spec-namespaced/no-custom-config/Cargo.toml index d70876eab7b1..2f722e02d681 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/Cargo.toml +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/Cargo.toml @@ -1,10 +1,3 @@ -# `name`, `repository`, `homepage`, `authors`, and `keywords` are Fern's — -# they identify the SDK template's source on crates.io. The fern-cli -# generator does NOT rewrite this block when producing your CLI; only the -# [[bin]] entry below is templated. If you want to publish *your* CLI as -# its own crate on crates.io, edit this block to your org's metadata. -# The [lib] name (`fern_cli_sdk`) is the import path every `use -# fern_cli_sdk::...` site in src/ depends on — do NOT rename it. [package] name = "fern-cli-sdk" version = "0.18.1" @@ -13,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -22,19 +14,10 @@ categories = ["command-line-utilities", "web-programming"] name = "fern_cli_sdk" path = "src/lib.rs" -# Rewritten by the fern-cli generator's `patchCargoToml` step — both the -# `name` and `path` are replaced with the derived binary name so users -# get `cargo install`-able binaries named after their API rather than -# the template's literal "openapi-fixture". [[bin]] name = "acme-versioned" path = "cli/acme-versioned/main.rs" -# Internal tool used by the SDK template itself — not the user's CLI. -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" - [features] # TLS backend selection. # @@ -81,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/cli/acme-versioned/main.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/cli/acme-versioned/main.rs index 045e644d42d0..c59e66401a99 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/cli/acme-versioned/main.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/cli/acme-versioned/main.rs @@ -1,13 +1,17 @@ // Auto-generated by @fern-api/cli-generator's copySpecs step. // Edit the SDK template / generator if you need to change the shape. -use fern_cli_sdk::openapi::CliApp; +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; +use fern_cli_sdk::auth::{BearerAuth}; fn main() { CliApp::new("acme-versioned") - .spec_under("v1", include_str!("openapi0.json")) - .spec_under("v2", include_str!("openapi1.json")) - .auth_scheme_env("bearerAuth", "ACME_VERSIONED_BEARER_AUTH_TOKEN") - .auth_scheme_env("apiKey", "ACME_VERSIONED_API_KEY_API_KEY") + .auth(BearerAuth::new("bearerAuth").env("ACME_VERSIONED_TOKEN")) + .binding( + OpenApiBinding::new() + .spec_under("v1", include_str!("openapi0.json")) + .spec_under("v2", include_str!("openapi1.json")) + ) .run() } diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/dist-workspace.toml b/seed/cli/cli-multi-spec-namespaced/no-custom-config/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/dist-workspace.toml +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/app.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/arg_source.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/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/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/auth/builder.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/auth/builder.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/auth/mod.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/auth/mod.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/auth/root_builder.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/binding.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/cli_args.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/cli_args.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/completions.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/completions.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/custom_commands.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/custom_commands.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/early_intercept.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/early_intercept.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/error.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/error.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/formatter.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/formatter.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/graphql/app.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/graphql/app.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/graphql/binding.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/graphql/commands.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/graphql/commands.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/graphql/mod.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/graphql/mod.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/hooks.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/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/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/lib.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/lib.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/logging.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/logging.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/man.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/man.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/__fixtures__/openapi.json b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 9b465f33a3e9..000000000000 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Test Fixture API", - "version": "1.0.0" - }, - "paths": { - "/users": { - "get": { - "x-fern-sdk-group-name": ["users"], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "parameters": [ - { - "name": "limit", - "in": "query", - "schema": { "type": "integer" } - } - ], - "responses": { - "200": { "description": "OK" } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": ["files"], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { "description": "OK" } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": ["files"], - "x-fern-sdk-method-name": "thumbnail", - "operationId": "files_thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { "description": "OK" } - } - } - } - } -} diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/app.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/app.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/binding.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/commands.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/commands.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/discovery.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/discovery.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/executor.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/executor.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/help.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/help.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/mod.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/mod.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/overlay.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/overlay.rs index d3b0f3cd72b0..85659b5da950 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/overlay.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1887,12 +1887,48 @@ actions: ); } - // (Previously: an integration smoke that exercised the rich - // template fixture's groups/methods after overlay. Coverage moved - // to `tests/cli_integration.rs` + `tests/openapi_fixture_wire.rs` - // — both of which exec the openapi-fixture bin against the rich - // fixture and assert deeper than this lib test ever could. The - // remaining `test_overlay_on_fixture_spec` above already covers - // the overlay→merge→build_doc lib path against the tiny shipped - // fixture.) + #[test] + fn test_overlay_on_fixture_spec_builds_cli_app() { + use crate::openapi::CliApp; + + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); + let overlay = r#" +overlay: "1.0.0" +info: + title: fixture-overlay + version: "1.0.0" +actions: + - target: "$.paths['/files/{file_id}/thumbnail']" + remove: true +"#; + + let app = CliApp::new("overlay-fixture") + .spec(spec) + .overlay(overlay); + let doc = app.build_doc().unwrap(); + + // files and folders groups should still exist + assert!(doc.resources.contains_key("files"), "files group missing"); + assert!(doc.resources.contains_key("folders"), "folders group missing"); + assert!(doc.resources.contains_key("users"), "users group missing"); + + // getThumbnail should be gone from the files resource + let files = &doc.resources["files"]; + assert!( + !files.methods.contains_key("getThumbnail"), + "getThumbnail should be removed: {:?}", + files.methods.keys().collect::>() + ); + // Other file operations should still exist + assert!( + files.methods.contains_key("get"), + "get should remain: {:?}", + files.methods.keys().collect::>() + ); + assert!( + files.methods.contains_key("update"), + "update should remain: {:?}", + files.methods.keys().collect::>() + ); + } } diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/parser.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/parser.rs +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/skill_emitter.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/stability.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/cli-multi-spec-namespaced/no-custom-config/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/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/auth_routing_wire.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/common/mod.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/lib_api.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/openapi_streaming_wire.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/tls_env_vars.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/websocket_wire.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/x_name_server_alias_wire.rs b/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/cli-multi-spec-namespaced/no-custom-config/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/cli-multi-spec/no-custom-config/.github/workflows/ci.yml b/seed/cli/cli-multi-spec/no-custom-config/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/cli-multi-spec/no-custom-config/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/cli-multi-spec/no-custom-config/.github/workflows/release.yml b/seed/cli/cli-multi-spec/no-custom-config/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/cli-multi-spec/no-custom-config/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/cli-multi-spec/no-custom-config/Cargo.lock b/seed/cli/cli-multi-spec/no-custom-config/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/Cargo.lock +++ b/seed/cli/cli-multi-spec/no-custom-config/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/cli-multi-spec/no-custom-config/Cargo.toml b/seed/cli/cli-multi-spec/no-custom-config/Cargo.toml index ee39958321fa..f8f095ee113f 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/Cargo.toml +++ b/seed/cli/cli-multi-spec/no-custom-config/Cargo.toml @@ -1,10 +1,3 @@ -# `name`, `repository`, `homepage`, `authors`, and `keywords` are Fern's — -# they identify the SDK template's source on crates.io. The fern-cli -# generator does NOT rewrite this block when producing your CLI; only the -# [[bin]] entry below is templated. If you want to publish *your* CLI as -# its own crate on crates.io, edit this block to your org's metadata. -# The [lib] name (`fern_cli_sdk`) is the import path every `use -# fern_cli_sdk::...` site in src/ depends on — do NOT rename it. [package] name = "fern-cli-sdk" version = "0.18.1" @@ -13,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -22,19 +14,10 @@ categories = ["command-line-utilities", "web-programming"] name = "fern_cli_sdk" path = "src/lib.rs" -# Rewritten by the fern-cli generator's `patchCargoToml` step — both the -# `name` and `path` are replaced with the derived binary name so users -# get `cargo install`-able binaries named after their API rather than -# the template's literal "openapi-fixture". [[bin]] name = "acme-cli" path = "cli/acme-cli/main.rs" -# Internal tool used by the SDK template itself — not the user's CLI. -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" - [features] # TLS backend selection. # @@ -81,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/cli-multi-spec/no-custom-config/cli/acme-cli/main.rs b/seed/cli/cli-multi-spec/no-custom-config/cli/acme-cli/main.rs index b99b9272e05d..2cae7f93154e 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/cli/acme-cli/main.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/cli/acme-cli/main.rs @@ -1,11 +1,15 @@ // Auto-generated by @fern-api/cli-generator's copySpecs step. // Edit the SDK template / generator if you need to change the shape. -use fern_cli_sdk::openapi::CliApp; +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; fn main() { CliApp::new("acme-cli") - .spec(include_str!("openapi0.json")) - .spec(include_str!("openapi1.json")) + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + .spec(include_str!("openapi1.json")) + ) .run() } diff --git a/seed/cli/cli-multi-spec/no-custom-config/dist-workspace.toml b/seed/cli/cli-multi-spec/no-custom-config/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/dist-workspace.toml +++ b/seed/cli/cli-multi-spec/no-custom-config/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/app.rs b/seed/cli/cli-multi-spec/no-custom-config/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/cli-multi-spec/no-custom-config/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/cli-multi-spec/no-custom-config/src/arg_source.rs b/seed/cli/cli-multi-spec/no-custom-config/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/cli-multi-spec/no-custom-config/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/seed/cli/cli-multi-spec/no-custom-config/src/auth/builder.rs b/seed/cli/cli-multi-spec/no-custom-config/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/auth/builder.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/auth/mod.rs b/seed/cli/cli-multi-spec/no-custom-config/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/auth/mod.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/auth/root_builder.rs b/seed/cli/cli-multi-spec/no-custom-config/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/cli-multi-spec/no-custom-config/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/binding.rs b/seed/cli/cli-multi-spec/no-custom-config/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/cli-multi-spec/no-custom-config/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/cli-multi-spec/no-custom-config/src/cli_args.rs b/seed/cli/cli-multi-spec/no-custom-config/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/cli_args.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/cli-multi-spec/no-custom-config/src/completions.rs b/seed/cli/cli-multi-spec/no-custom-config/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/completions.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/custom_commands.rs b/seed/cli/cli-multi-spec/no-custom-config/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/custom_commands.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/early_intercept.rs b/seed/cli/cli-multi-spec/no-custom-config/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/early_intercept.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/error.rs b/seed/cli/cli-multi-spec/no-custom-config/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/error.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/cli-multi-spec/no-custom-config/src/formatter.rs b/seed/cli/cli-multi-spec/no-custom-config/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/formatter.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/graphql/app.rs b/seed/cli/cli-multi-spec/no-custom-config/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/graphql/app.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/graphql/binding.rs b/seed/cli/cli-multi-spec/no-custom-config/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/cli-multi-spec/no-custom-config/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/graphql/commands.rs b/seed/cli/cli-multi-spec/no-custom-config/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/graphql/commands.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/graphql/mod.rs b/seed/cli/cli-multi-spec/no-custom-config/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/graphql/mod.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/cli-multi-spec/no-custom-config/src/hooks.rs b/seed/cli/cli-multi-spec/no-custom-config/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/cli-multi-spec/no-custom-config/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/seed/cli/cli-multi-spec/no-custom-config/src/lib.rs b/seed/cli/cli-multi-spec/no-custom-config/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/lib.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/logging.rs b/seed/cli/cli-multi-spec/no-custom-config/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/logging.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/man.rs b/seed/cli/cli-multi-spec/no-custom-config/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/man.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/__fixtures__/openapi.json b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 9b465f33a3e9..000000000000 --- a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Test Fixture API", - "version": "1.0.0" - }, - "paths": { - "/users": { - "get": { - "x-fern-sdk-group-name": ["users"], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "parameters": [ - { - "name": "limit", - "in": "query", - "schema": { "type": "integer" } - } - ], - "responses": { - "200": { "description": "OK" } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": ["files"], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { "description": "OK" } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": ["files"], - "x-fern-sdk-method-name": "thumbnail", - "operationId": "files_thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { "description": "OK" } - } - } - } - } -} diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/app.rs b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/app.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/binding.rs b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/commands.rs b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/commands.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/discovery.rs b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/discovery.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/executor.rs b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/executor.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/help.rs b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/help.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/mod.rs b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/mod.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/cli-multi-spec/no-custom-config/src/openapi/overlay.rs b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/overlay.rs index d3b0f3cd72b0..85659b5da950 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/overlay.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1887,12 +1887,48 @@ actions: ); } - // (Previously: an integration smoke that exercised the rich - // template fixture's groups/methods after overlay. Coverage moved - // to `tests/cli_integration.rs` + `tests/openapi_fixture_wire.rs` - // — both of which exec the openapi-fixture bin against the rich - // fixture and assert deeper than this lib test ever could. The - // remaining `test_overlay_on_fixture_spec` above already covers - // the overlay→merge→build_doc lib path against the tiny shipped - // fixture.) + #[test] + fn test_overlay_on_fixture_spec_builds_cli_app() { + use crate::openapi::CliApp; + + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); + let overlay = r#" +overlay: "1.0.0" +info: + title: fixture-overlay + version: "1.0.0" +actions: + - target: "$.paths['/files/{file_id}/thumbnail']" + remove: true +"#; + + let app = CliApp::new("overlay-fixture") + .spec(spec) + .overlay(overlay); + let doc = app.build_doc().unwrap(); + + // files and folders groups should still exist + assert!(doc.resources.contains_key("files"), "files group missing"); + assert!(doc.resources.contains_key("folders"), "folders group missing"); + assert!(doc.resources.contains_key("users"), "users group missing"); + + // getThumbnail should be gone from the files resource + let files = &doc.resources["files"]; + assert!( + !files.methods.contains_key("getThumbnail"), + "getThumbnail should be removed: {:?}", + files.methods.keys().collect::>() + ); + // Other file operations should still exist + assert!( + files.methods.contains_key("get"), + "get should remain: {:?}", + files.methods.keys().collect::>() + ); + assert!( + files.methods.contains_key("update"), + "update should remain: {:?}", + files.methods.keys().collect::>() + ); + } } diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/parser.rs b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/parser.rs +++ b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/cli-multi-spec/no-custom-config/src/openapi/skill_emitter.rs b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/cli-multi-spec/no-custom-config/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/cli-multi-spec/no-custom-config/src/stability.rs b/seed/cli/cli-multi-spec/no-custom-config/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/cli-multi-spec/no-custom-config/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/seed/cli/cli-multi-spec/no-custom-config/tests/auth_routing_wire.rs b/seed/cli/cli-multi-spec/no-custom-config/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/cli-multi-spec/no-custom-config/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/cli-multi-spec/no-custom-config/tests/common/mod.rs b/seed/cli/cli-multi-spec/no-custom-config/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/cli-multi-spec/no-custom-config/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/cli-multi-spec/no-custom-config/tests/lib_api.rs b/seed/cli/cli-multi-spec/no-custom-config/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/cli-multi-spec/no-custom-config/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/cli-multi-spec/no-custom-config/tests/openapi_streaming_wire.rs b/seed/cli/cli-multi-spec/no-custom-config/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/cli-multi-spec/no-custom-config/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/cli-multi-spec/no-custom-config/tests/tls_env_vars.rs b/seed/cli/cli-multi-spec/no-custom-config/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/cli-multi-spec/no-custom-config/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/cli-multi-spec/no-custom-config/tests/websocket_wire.rs b/seed/cli/cli-multi-spec/no-custom-config/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/cli-multi-spec/no-custom-config/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/cli-multi-spec/no-custom-config/tests/x_name_server_alias_wire.rs b/seed/cli/cli-multi-spec/no-custom-config/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/cli-multi-spec/no-custom-config/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/file-upload-openapi/.github/workflows/ci.yml b/seed/cli/file-upload-openapi/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/file-upload-openapi/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/file-upload-openapi/.github/workflows/release.yml b/seed/cli/file-upload-openapi/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/file-upload-openapi/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/file-upload-openapi/Cargo.lock b/seed/cli/file-upload-openapi/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/file-upload-openapi/Cargo.lock +++ b/seed/cli/file-upload-openapi/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/file-upload-openapi/Cargo.toml b/seed/cli/file-upload-openapi/Cargo.toml index 3173f130532a..eb9c7d79c33b 100644 --- a/seed/cli/file-upload-openapi/Cargo.toml +++ b/seed/cli/file-upload-openapi/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "api" +path = "cli/api/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/file-upload-openapi/cli/api/main.rs b/seed/cli/file-upload-openapi/cli/api/main.rs new file mode 100644 index 000000000000..69746a83867a --- /dev/null +++ b/seed/cli/file-upload-openapi/cli/api/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("api") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/file-upload-openapi/cli/openapi-fixture/openapi0.json b/seed/cli/file-upload-openapi/cli/api/openapi0.json similarity index 100% rename from seed/cli/file-upload-openapi/cli/openapi-fixture/openapi0.json rename to seed/cli/file-upload-openapi/cli/api/openapi0.json diff --git a/seed/cli/file-upload-openapi/cli/openapi-fixture/main.rs b/seed/cli/file-upload-openapi/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/file-upload-openapi/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/file-upload-openapi/dist-workspace.toml b/seed/cli/file-upload-openapi/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/file-upload-openapi/dist-workspace.toml +++ b/seed/cli/file-upload-openapi/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/file-upload-openapi/src/app.rs b/seed/cli/file-upload-openapi/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/file-upload-openapi/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/file-upload-openapi/src/arg_source.rs b/seed/cli/file-upload-openapi/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/file-upload-openapi/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/seed/cli/file-upload-openapi/src/auth/builder.rs b/seed/cli/file-upload-openapi/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/file-upload-openapi/src/auth/builder.rs +++ b/seed/cli/file-upload-openapi/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/file-upload-openapi/src/auth/mod.rs b/seed/cli/file-upload-openapi/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/file-upload-openapi/src/auth/mod.rs +++ b/seed/cli/file-upload-openapi/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/file-upload-openapi/src/auth/root_builder.rs b/seed/cli/file-upload-openapi/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/file-upload-openapi/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/file-upload-openapi/src/binding.rs b/seed/cli/file-upload-openapi/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/file-upload-openapi/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/file-upload-openapi/src/cli_args.rs b/seed/cli/file-upload-openapi/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/file-upload-openapi/src/cli_args.rs +++ b/seed/cli/file-upload-openapi/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/file-upload-openapi/src/completions.rs b/seed/cli/file-upload-openapi/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/file-upload-openapi/src/completions.rs +++ b/seed/cli/file-upload-openapi/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/file-upload-openapi/src/custom_commands.rs b/seed/cli/file-upload-openapi/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/file-upload-openapi/src/custom_commands.rs +++ b/seed/cli/file-upload-openapi/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/file-upload-openapi/src/early_intercept.rs b/seed/cli/file-upload-openapi/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/file-upload-openapi/src/early_intercept.rs +++ b/seed/cli/file-upload-openapi/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/file-upload-openapi/src/error.rs b/seed/cli/file-upload-openapi/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/file-upload-openapi/src/error.rs +++ b/seed/cli/file-upload-openapi/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/file-upload-openapi/src/formatter.rs b/seed/cli/file-upload-openapi/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/file-upload-openapi/src/formatter.rs +++ b/seed/cli/file-upload-openapi/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/file-upload-openapi/src/graphql/app.rs b/seed/cli/file-upload-openapi/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/file-upload-openapi/src/graphql/app.rs +++ b/seed/cli/file-upload-openapi/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/file-upload-openapi/src/graphql/binding.rs b/seed/cli/file-upload-openapi/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/file-upload-openapi/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/file-upload-openapi/src/graphql/commands.rs b/seed/cli/file-upload-openapi/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/file-upload-openapi/src/graphql/commands.rs +++ b/seed/cli/file-upload-openapi/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/file-upload-openapi/src/graphql/mod.rs b/seed/cli/file-upload-openapi/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/file-upload-openapi/src/graphql/mod.rs +++ b/seed/cli/file-upload-openapi/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/file-upload-openapi/src/hooks.rs b/seed/cli/file-upload-openapi/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/file-upload-openapi/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/seed/cli/file-upload-openapi/src/lib.rs b/seed/cli/file-upload-openapi/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/file-upload-openapi/src/lib.rs +++ b/seed/cli/file-upload-openapi/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/file-upload-openapi/src/logging.rs b/seed/cli/file-upload-openapi/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/file-upload-openapi/src/logging.rs +++ b/seed/cli/file-upload-openapi/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/file-upload-openapi/src/man.rs b/seed/cli/file-upload-openapi/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/file-upload-openapi/src/man.rs +++ b/seed/cli/file-upload-openapi/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/file-upload-openapi/src/openapi/__fixtures__/openapi.json b/seed/cli/file-upload-openapi/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/file-upload-openapi/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/file-upload-openapi/src/openapi/app.rs b/seed/cli/file-upload-openapi/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/file-upload-openapi/src/openapi/app.rs +++ b/seed/cli/file-upload-openapi/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/file-upload-openapi/src/openapi/binding.rs b/seed/cli/file-upload-openapi/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/file-upload-openapi/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/file-upload-openapi/src/openapi/commands.rs b/seed/cli/file-upload-openapi/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/file-upload-openapi/src/openapi/commands.rs +++ b/seed/cli/file-upload-openapi/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/file-upload-openapi/src/openapi/discovery.rs b/seed/cli/file-upload-openapi/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/file-upload-openapi/src/openapi/discovery.rs +++ b/seed/cli/file-upload-openapi/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/file-upload-openapi/src/openapi/executor.rs b/seed/cli/file-upload-openapi/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/file-upload-openapi/src/openapi/executor.rs +++ b/seed/cli/file-upload-openapi/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/file-upload-openapi/src/openapi/help.rs b/seed/cli/file-upload-openapi/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/file-upload-openapi/src/openapi/help.rs +++ b/seed/cli/file-upload-openapi/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/file-upload-openapi/src/openapi/mod.rs b/seed/cli/file-upload-openapi/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/file-upload-openapi/src/openapi/mod.rs +++ b/seed/cli/file-upload-openapi/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/file-upload-openapi/src/openapi/overlay.rs b/seed/cli/file-upload-openapi/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/file-upload-openapi/src/openapi/overlay.rs +++ b/seed/cli/file-upload-openapi/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/file-upload-openapi/src/openapi/parser.rs b/seed/cli/file-upload-openapi/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/file-upload-openapi/src/openapi/parser.rs +++ b/seed/cli/file-upload-openapi/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/file-upload-openapi/src/openapi/skill_emitter.rs b/seed/cli/file-upload-openapi/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/file-upload-openapi/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/file-upload-openapi/src/stability.rs b/seed/cli/file-upload-openapi/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/file-upload-openapi/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/seed/cli/file-upload-openapi/tests/auth_routing_wire.rs b/seed/cli/file-upload-openapi/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/file-upload-openapi/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/file-upload-openapi/tests/common/mod.rs b/seed/cli/file-upload-openapi/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/file-upload-openapi/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/file-upload-openapi/tests/lib_api.rs b/seed/cli/file-upload-openapi/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/file-upload-openapi/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/file-upload-openapi/tests/openapi_streaming_wire.rs b/seed/cli/file-upload-openapi/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/file-upload-openapi/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/file-upload-openapi/tests/tls_env_vars.rs b/seed/cli/file-upload-openapi/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/file-upload-openapi/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/file-upload-openapi/tests/websocket_wire.rs b/seed/cli/file-upload-openapi/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/file-upload-openapi/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/file-upload-openapi/tests/x_name_server_alias_wire.rs b/seed/cli/file-upload-openapi/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/file-upload-openapi/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/imdb/.github/workflows/ci.yml b/seed/cli/imdb/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/imdb/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/imdb/.github/workflows/release.yml b/seed/cli/imdb/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/imdb/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/imdb/Cargo.lock b/seed/cli/imdb/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/imdb/Cargo.lock +++ b/seed/cli/imdb/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/imdb/Cargo.toml b/seed/cli/imdb/Cargo.toml index 3173f130532a..eb9c7d79c33b 100644 --- a/seed/cli/imdb/Cargo.toml +++ b/seed/cli/imdb/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "api" +path = "cli/api/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/imdb/cli/api/main.rs b/seed/cli/imdb/cli/api/main.rs new file mode 100644 index 000000000000..f31ba764b568 --- /dev/null +++ b/seed/cli/imdb/cli/api/main.rs @@ -0,0 +1,16 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; +use fern_cli_sdk::auth::{BearerAuth}; + +fn main() { + CliApp::new("api") + .auth(BearerAuth::new("bearer").env("API_TOKEN")) + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/imdb/cli/openapi-fixture/openapi0.json b/seed/cli/imdb/cli/api/openapi0.json similarity index 100% rename from seed/cli/imdb/cli/openapi-fixture/openapi0.json rename to seed/cli/imdb/cli/api/openapi0.json diff --git a/seed/cli/imdb/cli/openapi-fixture/main.rs b/seed/cli/imdb/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/imdb/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/imdb/dist-workspace.toml b/seed/cli/imdb/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/imdb/dist-workspace.toml +++ b/seed/cli/imdb/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/imdb/src/app.rs b/seed/cli/imdb/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/imdb/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/imdb/src/arg_source.rs b/seed/cli/imdb/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/imdb/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/seed/cli/imdb/src/auth/builder.rs b/seed/cli/imdb/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/imdb/src/auth/builder.rs +++ b/seed/cli/imdb/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/imdb/src/auth/mod.rs b/seed/cli/imdb/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/imdb/src/auth/mod.rs +++ b/seed/cli/imdb/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/imdb/src/auth/root_builder.rs b/seed/cli/imdb/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/imdb/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/imdb/src/binding.rs b/seed/cli/imdb/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/imdb/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/imdb/src/cli_args.rs b/seed/cli/imdb/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/imdb/src/cli_args.rs +++ b/seed/cli/imdb/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/imdb/src/completions.rs b/seed/cli/imdb/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/imdb/src/completions.rs +++ b/seed/cli/imdb/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/imdb/src/custom_commands.rs b/seed/cli/imdb/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/imdb/src/custom_commands.rs +++ b/seed/cli/imdb/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/imdb/src/early_intercept.rs b/seed/cli/imdb/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/imdb/src/early_intercept.rs +++ b/seed/cli/imdb/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/imdb/src/error.rs b/seed/cli/imdb/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/imdb/src/error.rs +++ b/seed/cli/imdb/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/imdb/src/formatter.rs b/seed/cli/imdb/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/imdb/src/formatter.rs +++ b/seed/cli/imdb/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/imdb/src/graphql/app.rs b/seed/cli/imdb/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/imdb/src/graphql/app.rs +++ b/seed/cli/imdb/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/imdb/src/graphql/binding.rs b/seed/cli/imdb/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/imdb/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/imdb/src/graphql/commands.rs b/seed/cli/imdb/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/imdb/src/graphql/commands.rs +++ b/seed/cli/imdb/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/imdb/src/graphql/mod.rs b/seed/cli/imdb/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/imdb/src/graphql/mod.rs +++ b/seed/cli/imdb/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/imdb/src/hooks.rs b/seed/cli/imdb/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/imdb/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/seed/cli/imdb/src/lib.rs b/seed/cli/imdb/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/imdb/src/lib.rs +++ b/seed/cli/imdb/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/imdb/src/logging.rs b/seed/cli/imdb/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/imdb/src/logging.rs +++ b/seed/cli/imdb/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/imdb/src/man.rs b/seed/cli/imdb/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/imdb/src/man.rs +++ b/seed/cli/imdb/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/imdb/src/openapi/__fixtures__/openapi.json b/seed/cli/imdb/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/imdb/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/imdb/src/openapi/app.rs b/seed/cli/imdb/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/imdb/src/openapi/app.rs +++ b/seed/cli/imdb/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/imdb/src/openapi/binding.rs b/seed/cli/imdb/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/imdb/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/imdb/src/openapi/commands.rs b/seed/cli/imdb/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/imdb/src/openapi/commands.rs +++ b/seed/cli/imdb/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/imdb/src/openapi/discovery.rs b/seed/cli/imdb/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/imdb/src/openapi/discovery.rs +++ b/seed/cli/imdb/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/imdb/src/openapi/executor.rs b/seed/cli/imdb/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/imdb/src/openapi/executor.rs +++ b/seed/cli/imdb/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/imdb/src/openapi/help.rs b/seed/cli/imdb/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/imdb/src/openapi/help.rs +++ b/seed/cli/imdb/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/imdb/src/openapi/mod.rs b/seed/cli/imdb/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/imdb/src/openapi/mod.rs +++ b/seed/cli/imdb/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/imdb/src/openapi/overlay.rs b/seed/cli/imdb/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/imdb/src/openapi/overlay.rs +++ b/seed/cli/imdb/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/imdb/src/openapi/parser.rs b/seed/cli/imdb/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/imdb/src/openapi/parser.rs +++ b/seed/cli/imdb/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/imdb/src/openapi/skill_emitter.rs b/seed/cli/imdb/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/imdb/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/imdb/src/stability.rs b/seed/cli/imdb/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/imdb/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/seed/cli/imdb/tests/auth_routing_wire.rs b/seed/cli/imdb/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/imdb/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/imdb/tests/common/mod.rs b/seed/cli/imdb/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/imdb/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/imdb/tests/lib_api.rs b/seed/cli/imdb/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/imdb/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/imdb/tests/openapi_streaming_wire.rs b/seed/cli/imdb/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/imdb/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/imdb/tests/tls_env_vars.rs b/seed/cli/imdb/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/imdb/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/imdb/tests/websocket_wire.rs b/seed/cli/imdb/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/imdb/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/imdb/tests/x_name_server_alias_wire.rs b/seed/cli/imdb/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/imdb/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/multi-url-environment-reference/.github/workflows/ci.yml b/seed/cli/multi-url-environment-reference/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/multi-url-environment-reference/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/multi-url-environment-reference/.github/workflows/release.yml b/seed/cli/multi-url-environment-reference/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/multi-url-environment-reference/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/multi-url-environment-reference/Cargo.lock b/seed/cli/multi-url-environment-reference/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/multi-url-environment-reference/Cargo.lock +++ b/seed/cli/multi-url-environment-reference/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/multi-url-environment-reference/Cargo.toml b/seed/cli/multi-url-environment-reference/Cargo.toml index 3173f130532a..7a22418ee9fd 100644 --- a/seed/cli/multi-url-environment-reference/Cargo.toml +++ b/seed/cli/multi-url-environment-reference/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "multi-url-environment-reference" +path = "cli/multi-url-environment-reference/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/multi-url-environment-reference/cli/multi-url-environment-reference/main.rs b/seed/cli/multi-url-environment-reference/cli/multi-url-environment-reference/main.rs new file mode 100644 index 000000000000..004d93ac1652 --- /dev/null +++ b/seed/cli/multi-url-environment-reference/cli/multi-url-environment-reference/main.rs @@ -0,0 +1,16 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; +use fern_cli_sdk::auth::{BearerAuth}; + +fn main() { + CliApp::new("multi-url-environment-reference") + .auth(BearerAuth::new("BearerAuth").env("MULTI_URL_ENVIRONMENT_REFERENCE_TOKEN")) + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/multi-url-environment-reference/cli/openapi-fixture/openapi0.json b/seed/cli/multi-url-environment-reference/cli/multi-url-environment-reference/openapi0.json similarity index 100% rename from seed/cli/multi-url-environment-reference/cli/openapi-fixture/openapi0.json rename to seed/cli/multi-url-environment-reference/cli/multi-url-environment-reference/openapi0.json diff --git a/seed/cli/multi-url-environment-reference/cli/openapi-fixture/main.rs b/seed/cli/multi-url-environment-reference/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/multi-url-environment-reference/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/multi-url-environment-reference/dist-workspace.toml b/seed/cli/multi-url-environment-reference/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/multi-url-environment-reference/dist-workspace.toml +++ b/seed/cli/multi-url-environment-reference/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/multi-url-environment-reference/src/app.rs b/seed/cli/multi-url-environment-reference/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/multi-url-environment-reference/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/multi-url-environment-reference/src/arg_source.rs b/seed/cli/multi-url-environment-reference/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/multi-url-environment-reference/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/seed/cli/multi-url-environment-reference/src/auth/builder.rs b/seed/cli/multi-url-environment-reference/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/multi-url-environment-reference/src/auth/builder.rs +++ b/seed/cli/multi-url-environment-reference/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/multi-url-environment-reference/src/auth/mod.rs b/seed/cli/multi-url-environment-reference/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/multi-url-environment-reference/src/auth/mod.rs +++ b/seed/cli/multi-url-environment-reference/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/multi-url-environment-reference/src/auth/root_builder.rs b/seed/cli/multi-url-environment-reference/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/multi-url-environment-reference/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/multi-url-environment-reference/src/binding.rs b/seed/cli/multi-url-environment-reference/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/multi-url-environment-reference/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/multi-url-environment-reference/src/cli_args.rs b/seed/cli/multi-url-environment-reference/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/multi-url-environment-reference/src/cli_args.rs +++ b/seed/cli/multi-url-environment-reference/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/multi-url-environment-reference/src/completions.rs b/seed/cli/multi-url-environment-reference/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/multi-url-environment-reference/src/completions.rs +++ b/seed/cli/multi-url-environment-reference/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/multi-url-environment-reference/src/custom_commands.rs b/seed/cli/multi-url-environment-reference/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/multi-url-environment-reference/src/custom_commands.rs +++ b/seed/cli/multi-url-environment-reference/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/multi-url-environment-reference/src/early_intercept.rs b/seed/cli/multi-url-environment-reference/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/multi-url-environment-reference/src/early_intercept.rs +++ b/seed/cli/multi-url-environment-reference/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/multi-url-environment-reference/src/error.rs b/seed/cli/multi-url-environment-reference/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/multi-url-environment-reference/src/error.rs +++ b/seed/cli/multi-url-environment-reference/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/multi-url-environment-reference/src/formatter.rs b/seed/cli/multi-url-environment-reference/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/multi-url-environment-reference/src/formatter.rs +++ b/seed/cli/multi-url-environment-reference/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/multi-url-environment-reference/src/graphql/app.rs b/seed/cli/multi-url-environment-reference/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/multi-url-environment-reference/src/graphql/app.rs +++ b/seed/cli/multi-url-environment-reference/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/multi-url-environment-reference/src/graphql/binding.rs b/seed/cli/multi-url-environment-reference/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/multi-url-environment-reference/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/multi-url-environment-reference/src/graphql/commands.rs b/seed/cli/multi-url-environment-reference/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/multi-url-environment-reference/src/graphql/commands.rs +++ b/seed/cli/multi-url-environment-reference/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/multi-url-environment-reference/src/graphql/mod.rs b/seed/cli/multi-url-environment-reference/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/multi-url-environment-reference/src/graphql/mod.rs +++ b/seed/cli/multi-url-environment-reference/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/multi-url-environment-reference/src/hooks.rs b/seed/cli/multi-url-environment-reference/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/multi-url-environment-reference/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/seed/cli/multi-url-environment-reference/src/lib.rs b/seed/cli/multi-url-environment-reference/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/multi-url-environment-reference/src/lib.rs +++ b/seed/cli/multi-url-environment-reference/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/multi-url-environment-reference/src/logging.rs b/seed/cli/multi-url-environment-reference/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/multi-url-environment-reference/src/logging.rs +++ b/seed/cli/multi-url-environment-reference/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/multi-url-environment-reference/src/man.rs b/seed/cli/multi-url-environment-reference/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/multi-url-environment-reference/src/man.rs +++ b/seed/cli/multi-url-environment-reference/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/multi-url-environment-reference/src/openapi/__fixtures__/openapi.json b/seed/cli/multi-url-environment-reference/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/multi-url-environment-reference/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/multi-url-environment-reference/src/openapi/app.rs b/seed/cli/multi-url-environment-reference/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/multi-url-environment-reference/src/openapi/app.rs +++ b/seed/cli/multi-url-environment-reference/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/multi-url-environment-reference/src/openapi/binding.rs b/seed/cli/multi-url-environment-reference/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/multi-url-environment-reference/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/multi-url-environment-reference/src/openapi/commands.rs b/seed/cli/multi-url-environment-reference/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/multi-url-environment-reference/src/openapi/commands.rs +++ b/seed/cli/multi-url-environment-reference/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/multi-url-environment-reference/src/openapi/discovery.rs b/seed/cli/multi-url-environment-reference/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/multi-url-environment-reference/src/openapi/discovery.rs +++ b/seed/cli/multi-url-environment-reference/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/multi-url-environment-reference/src/openapi/executor.rs b/seed/cli/multi-url-environment-reference/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/multi-url-environment-reference/src/openapi/executor.rs +++ b/seed/cli/multi-url-environment-reference/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/multi-url-environment-reference/src/openapi/help.rs b/seed/cli/multi-url-environment-reference/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/multi-url-environment-reference/src/openapi/help.rs +++ b/seed/cli/multi-url-environment-reference/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/multi-url-environment-reference/src/openapi/mod.rs b/seed/cli/multi-url-environment-reference/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/multi-url-environment-reference/src/openapi/mod.rs +++ b/seed/cli/multi-url-environment-reference/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/multi-url-environment-reference/src/openapi/overlay.rs b/seed/cli/multi-url-environment-reference/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/multi-url-environment-reference/src/openapi/overlay.rs +++ b/seed/cli/multi-url-environment-reference/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/multi-url-environment-reference/src/openapi/parser.rs b/seed/cli/multi-url-environment-reference/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/multi-url-environment-reference/src/openapi/parser.rs +++ b/seed/cli/multi-url-environment-reference/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/multi-url-environment-reference/src/openapi/skill_emitter.rs b/seed/cli/multi-url-environment-reference/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/multi-url-environment-reference/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/multi-url-environment-reference/src/stability.rs b/seed/cli/multi-url-environment-reference/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/multi-url-environment-reference/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/seed/cli/multi-url-environment-reference/tests/auth_routing_wire.rs b/seed/cli/multi-url-environment-reference/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/multi-url-environment-reference/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/multi-url-environment-reference/tests/common/mod.rs b/seed/cli/multi-url-environment-reference/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/multi-url-environment-reference/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/multi-url-environment-reference/tests/lib_api.rs b/seed/cli/multi-url-environment-reference/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/multi-url-environment-reference/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/multi-url-environment-reference/tests/openapi_streaming_wire.rs b/seed/cli/multi-url-environment-reference/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/multi-url-environment-reference/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/multi-url-environment-reference/tests/tls_env_vars.rs b/seed/cli/multi-url-environment-reference/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/multi-url-environment-reference/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/multi-url-environment-reference/tests/websocket_wire.rs b/seed/cli/multi-url-environment-reference/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/multi-url-environment-reference/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/multi-url-environment-reference/tests/x_name_server_alias_wire.rs b/seed/cli/multi-url-environment-reference/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/multi-url-environment-reference/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/no-content-response/.github/workflows/ci.yml b/seed/cli/no-content-response/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/no-content-response/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/no-content-response/.github/workflows/release.yml b/seed/cli/no-content-response/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/no-content-response/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/no-content-response/Cargo.lock b/seed/cli/no-content-response/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/no-content-response/Cargo.lock +++ b/seed/cli/no-content-response/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/no-content-response/Cargo.toml b/seed/cli/no-content-response/Cargo.toml index 3173f130532a..ca4a5fcc3635 100644 --- a/seed/cli/no-content-response/Cargo.toml +++ b/seed/cli/no-content-response/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "no-content-response" +path = "cli/no-content-response/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/no-content-response/cli/no-content-response/main.rs b/seed/cli/no-content-response/cli/no-content-response/main.rs new file mode 100644 index 000000000000..f8fab64e6fe9 --- /dev/null +++ b/seed/cli/no-content-response/cli/no-content-response/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("no-content-response") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/no-content-response/cli/openapi-fixture/openapi0.json b/seed/cli/no-content-response/cli/no-content-response/openapi0.json similarity index 100% rename from seed/cli/no-content-response/cli/openapi-fixture/openapi0.json rename to seed/cli/no-content-response/cli/no-content-response/openapi0.json diff --git a/seed/cli/no-content-response/cli/openapi-fixture/main.rs b/seed/cli/no-content-response/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/no-content-response/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/no-content-response/dist-workspace.toml b/seed/cli/no-content-response/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/no-content-response/dist-workspace.toml +++ b/seed/cli/no-content-response/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/no-content-response/src/app.rs b/seed/cli/no-content-response/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/no-content-response/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/no-content-response/src/arg_source.rs b/seed/cli/no-content-response/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/no-content-response/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/seed/cli/no-content-response/src/auth/builder.rs b/seed/cli/no-content-response/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/no-content-response/src/auth/builder.rs +++ b/seed/cli/no-content-response/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/no-content-response/src/auth/mod.rs b/seed/cli/no-content-response/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/no-content-response/src/auth/mod.rs +++ b/seed/cli/no-content-response/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/no-content-response/src/auth/root_builder.rs b/seed/cli/no-content-response/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/no-content-response/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/no-content-response/src/binding.rs b/seed/cli/no-content-response/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/no-content-response/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/no-content-response/src/cli_args.rs b/seed/cli/no-content-response/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/no-content-response/src/cli_args.rs +++ b/seed/cli/no-content-response/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/no-content-response/src/completions.rs b/seed/cli/no-content-response/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/no-content-response/src/completions.rs +++ b/seed/cli/no-content-response/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/no-content-response/src/custom_commands.rs b/seed/cli/no-content-response/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/no-content-response/src/custom_commands.rs +++ b/seed/cli/no-content-response/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/no-content-response/src/early_intercept.rs b/seed/cli/no-content-response/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/no-content-response/src/early_intercept.rs +++ b/seed/cli/no-content-response/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/no-content-response/src/error.rs b/seed/cli/no-content-response/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/no-content-response/src/error.rs +++ b/seed/cli/no-content-response/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/no-content-response/src/formatter.rs b/seed/cli/no-content-response/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/no-content-response/src/formatter.rs +++ b/seed/cli/no-content-response/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/no-content-response/src/graphql/app.rs b/seed/cli/no-content-response/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/no-content-response/src/graphql/app.rs +++ b/seed/cli/no-content-response/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/no-content-response/src/graphql/binding.rs b/seed/cli/no-content-response/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/no-content-response/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/no-content-response/src/graphql/commands.rs b/seed/cli/no-content-response/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/no-content-response/src/graphql/commands.rs +++ b/seed/cli/no-content-response/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/no-content-response/src/graphql/mod.rs b/seed/cli/no-content-response/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/no-content-response/src/graphql/mod.rs +++ b/seed/cli/no-content-response/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/no-content-response/src/hooks.rs b/seed/cli/no-content-response/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/no-content-response/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/seed/cli/no-content-response/src/lib.rs b/seed/cli/no-content-response/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/no-content-response/src/lib.rs +++ b/seed/cli/no-content-response/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/no-content-response/src/logging.rs b/seed/cli/no-content-response/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/no-content-response/src/logging.rs +++ b/seed/cli/no-content-response/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/no-content-response/src/man.rs b/seed/cli/no-content-response/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/no-content-response/src/man.rs +++ b/seed/cli/no-content-response/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/no-content-response/src/openapi/__fixtures__/openapi.json b/seed/cli/no-content-response/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/no-content-response/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/no-content-response/src/openapi/app.rs b/seed/cli/no-content-response/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/no-content-response/src/openapi/app.rs +++ b/seed/cli/no-content-response/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/no-content-response/src/openapi/binding.rs b/seed/cli/no-content-response/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/no-content-response/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/no-content-response/src/openapi/commands.rs b/seed/cli/no-content-response/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/no-content-response/src/openapi/commands.rs +++ b/seed/cli/no-content-response/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/no-content-response/src/openapi/discovery.rs b/seed/cli/no-content-response/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/no-content-response/src/openapi/discovery.rs +++ b/seed/cli/no-content-response/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/no-content-response/src/openapi/executor.rs b/seed/cli/no-content-response/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/no-content-response/src/openapi/executor.rs +++ b/seed/cli/no-content-response/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/no-content-response/src/openapi/help.rs b/seed/cli/no-content-response/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/no-content-response/src/openapi/help.rs +++ b/seed/cli/no-content-response/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/no-content-response/src/openapi/mod.rs b/seed/cli/no-content-response/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/no-content-response/src/openapi/mod.rs +++ b/seed/cli/no-content-response/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/no-content-response/src/openapi/overlay.rs b/seed/cli/no-content-response/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/no-content-response/src/openapi/overlay.rs +++ b/seed/cli/no-content-response/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/no-content-response/src/openapi/parser.rs b/seed/cli/no-content-response/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/no-content-response/src/openapi/parser.rs +++ b/seed/cli/no-content-response/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/no-content-response/src/openapi/skill_emitter.rs b/seed/cli/no-content-response/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/no-content-response/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/no-content-response/src/stability.rs b/seed/cli/no-content-response/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/no-content-response/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/seed/cli/no-content-response/tests/auth_routing_wire.rs b/seed/cli/no-content-response/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/no-content-response/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/no-content-response/tests/common/mod.rs b/seed/cli/no-content-response/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/no-content-response/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/no-content-response/tests/lib_api.rs b/seed/cli/no-content-response/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/no-content-response/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/no-content-response/tests/openapi_streaming_wire.rs b/seed/cli/no-content-response/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/no-content-response/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/no-content-response/tests/tls_env_vars.rs b/seed/cli/no-content-response/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/no-content-response/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/no-content-response/tests/websocket_wire.rs b/seed/cli/no-content-response/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/no-content-response/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/no-content-response/tests/x_name_server_alias_wire.rs b/seed/cli/no-content-response/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/no-content-response/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/null-type/.github/workflows/ci.yml b/seed/cli/null-type/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/null-type/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/null-type/.github/workflows/release.yml b/seed/cli/null-type/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/null-type/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/null-type/Cargo.lock b/seed/cli/null-type/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/null-type/Cargo.lock +++ b/seed/cli/null-type/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/null-type/Cargo.toml b/seed/cli/null-type/Cargo.toml index 3173f130532a..1519663ef199 100644 --- a/seed/cli/null-type/Cargo.toml +++ b/seed/cli/null-type/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "null-type" +path = "cli/null-type/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/null-type/cli/null-type/main.rs b/seed/cli/null-type/cli/null-type/main.rs new file mode 100644 index 000000000000..45b122995dac --- /dev/null +++ b/seed/cli/null-type/cli/null-type/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("null-type") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/null-type/cli/openapi-fixture/openapi0.json b/seed/cli/null-type/cli/null-type/openapi0.json similarity index 100% rename from seed/cli/null-type/cli/openapi-fixture/openapi0.json rename to seed/cli/null-type/cli/null-type/openapi0.json diff --git a/seed/cli/null-type/cli/openapi-fixture/main.rs b/seed/cli/null-type/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/null-type/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/null-type/dist-workspace.toml b/seed/cli/null-type/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/null-type/dist-workspace.toml +++ b/seed/cli/null-type/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/null-type/src/app.rs b/seed/cli/null-type/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/null-type/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/null-type/src/arg_source.rs b/seed/cli/null-type/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/null-type/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/seed/cli/null-type/src/auth/builder.rs b/seed/cli/null-type/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/null-type/src/auth/builder.rs +++ b/seed/cli/null-type/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/null-type/src/auth/mod.rs b/seed/cli/null-type/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/null-type/src/auth/mod.rs +++ b/seed/cli/null-type/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/null-type/src/auth/root_builder.rs b/seed/cli/null-type/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/null-type/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/null-type/src/binding.rs b/seed/cli/null-type/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/null-type/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/null-type/src/cli_args.rs b/seed/cli/null-type/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/null-type/src/cli_args.rs +++ b/seed/cli/null-type/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/null-type/src/completions.rs b/seed/cli/null-type/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/null-type/src/completions.rs +++ b/seed/cli/null-type/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/null-type/src/custom_commands.rs b/seed/cli/null-type/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/null-type/src/custom_commands.rs +++ b/seed/cli/null-type/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/null-type/src/early_intercept.rs b/seed/cli/null-type/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/null-type/src/early_intercept.rs +++ b/seed/cli/null-type/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/null-type/src/error.rs b/seed/cli/null-type/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/null-type/src/error.rs +++ b/seed/cli/null-type/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/null-type/src/formatter.rs b/seed/cli/null-type/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/null-type/src/formatter.rs +++ b/seed/cli/null-type/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/null-type/src/graphql/app.rs b/seed/cli/null-type/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/null-type/src/graphql/app.rs +++ b/seed/cli/null-type/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/null-type/src/graphql/binding.rs b/seed/cli/null-type/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/null-type/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/null-type/src/graphql/commands.rs b/seed/cli/null-type/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/null-type/src/graphql/commands.rs +++ b/seed/cli/null-type/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/null-type/src/graphql/mod.rs b/seed/cli/null-type/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/null-type/src/graphql/mod.rs +++ b/seed/cli/null-type/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/null-type/src/hooks.rs b/seed/cli/null-type/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/null-type/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/seed/cli/null-type/src/lib.rs b/seed/cli/null-type/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/null-type/src/lib.rs +++ b/seed/cli/null-type/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/null-type/src/logging.rs b/seed/cli/null-type/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/null-type/src/logging.rs +++ b/seed/cli/null-type/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/null-type/src/man.rs b/seed/cli/null-type/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/null-type/src/man.rs +++ b/seed/cli/null-type/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/null-type/src/openapi/__fixtures__/openapi.json b/seed/cli/null-type/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/null-type/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/null-type/src/openapi/app.rs b/seed/cli/null-type/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/null-type/src/openapi/app.rs +++ b/seed/cli/null-type/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/null-type/src/openapi/binding.rs b/seed/cli/null-type/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/null-type/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/null-type/src/openapi/commands.rs b/seed/cli/null-type/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/null-type/src/openapi/commands.rs +++ b/seed/cli/null-type/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/null-type/src/openapi/discovery.rs b/seed/cli/null-type/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/null-type/src/openapi/discovery.rs +++ b/seed/cli/null-type/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/null-type/src/openapi/executor.rs b/seed/cli/null-type/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/null-type/src/openapi/executor.rs +++ b/seed/cli/null-type/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/null-type/src/openapi/help.rs b/seed/cli/null-type/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/null-type/src/openapi/help.rs +++ b/seed/cli/null-type/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/null-type/src/openapi/mod.rs b/seed/cli/null-type/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/null-type/src/openapi/mod.rs +++ b/seed/cli/null-type/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/null-type/src/openapi/overlay.rs b/seed/cli/null-type/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/null-type/src/openapi/overlay.rs +++ b/seed/cli/null-type/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/null-type/src/openapi/parser.rs b/seed/cli/null-type/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/null-type/src/openapi/parser.rs +++ b/seed/cli/null-type/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/null-type/src/openapi/skill_emitter.rs b/seed/cli/null-type/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/null-type/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/null-type/src/stability.rs b/seed/cli/null-type/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/null-type/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/seed/cli/null-type/tests/auth_routing_wire.rs b/seed/cli/null-type/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/null-type/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/null-type/tests/common/mod.rs b/seed/cli/null-type/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/null-type/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/null-type/tests/lib_api.rs b/seed/cli/null-type/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/null-type/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/null-type/tests/openapi_streaming_wire.rs b/seed/cli/null-type/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/null-type/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/null-type/tests/tls_env_vars.rs b/seed/cli/null-type/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/null-type/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/null-type/tests/websocket_wire.rs b/seed/cli/null-type/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/null-type/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/null-type/tests/x_name_server_alias_wire.rs b/seed/cli/null-type/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/null-type/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/nullable-allof-extends/.github/workflows/ci.yml b/seed/cli/nullable-allof-extends/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/nullable-allof-extends/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/nullable-allof-extends/.github/workflows/release.yml b/seed/cli/nullable-allof-extends/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/nullable-allof-extends/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/nullable-allof-extends/Cargo.lock b/seed/cli/nullable-allof-extends/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/nullable-allof-extends/Cargo.lock +++ b/seed/cli/nullable-allof-extends/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/nullable-allof-extends/Cargo.toml b/seed/cli/nullable-allof-extends/Cargo.toml index 3173f130532a..788af76b1b07 100644 --- a/seed/cli/nullable-allof-extends/Cargo.toml +++ b/seed/cli/nullable-allof-extends/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "nullable-allof-extends-test" +path = "cli/nullable-allof-extends-test/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/nullable-allof-extends/cli/nullable-allof-extends-test/main.rs b/seed/cli/nullable-allof-extends/cli/nullable-allof-extends-test/main.rs new file mode 100644 index 000000000000..673b5d0b31a4 --- /dev/null +++ b/seed/cli/nullable-allof-extends/cli/nullable-allof-extends-test/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("nullable-allof-extends-test") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/nullable-allof-extends/cli/openapi-fixture/openapi0.json b/seed/cli/nullable-allof-extends/cli/nullable-allof-extends-test/openapi0.json similarity index 100% rename from seed/cli/nullable-allof-extends/cli/openapi-fixture/openapi0.json rename to seed/cli/nullable-allof-extends/cli/nullable-allof-extends-test/openapi0.json diff --git a/seed/cli/nullable-allof-extends/cli/openapi-fixture/main.rs b/seed/cli/nullable-allof-extends/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/nullable-allof-extends/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/nullable-allof-extends/dist-workspace.toml b/seed/cli/nullable-allof-extends/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/nullable-allof-extends/dist-workspace.toml +++ b/seed/cli/nullable-allof-extends/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/nullable-allof-extends/src/app.rs b/seed/cli/nullable-allof-extends/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/nullable-allof-extends/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/nullable-allof-extends/src/arg_source.rs b/seed/cli/nullable-allof-extends/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/nullable-allof-extends/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/seed/cli/nullable-allof-extends/src/auth/builder.rs b/seed/cli/nullable-allof-extends/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/nullable-allof-extends/src/auth/builder.rs +++ b/seed/cli/nullable-allof-extends/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/nullable-allof-extends/src/auth/mod.rs b/seed/cli/nullable-allof-extends/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/nullable-allof-extends/src/auth/mod.rs +++ b/seed/cli/nullable-allof-extends/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/nullable-allof-extends/src/auth/root_builder.rs b/seed/cli/nullable-allof-extends/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/nullable-allof-extends/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/nullable-allof-extends/src/binding.rs b/seed/cli/nullable-allof-extends/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/nullable-allof-extends/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/nullable-allof-extends/src/cli_args.rs b/seed/cli/nullable-allof-extends/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/nullable-allof-extends/src/cli_args.rs +++ b/seed/cli/nullable-allof-extends/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/nullable-allof-extends/src/completions.rs b/seed/cli/nullable-allof-extends/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/nullable-allof-extends/src/completions.rs +++ b/seed/cli/nullable-allof-extends/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/nullable-allof-extends/src/custom_commands.rs b/seed/cli/nullable-allof-extends/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/nullable-allof-extends/src/custom_commands.rs +++ b/seed/cli/nullable-allof-extends/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/nullable-allof-extends/src/early_intercept.rs b/seed/cli/nullable-allof-extends/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/nullable-allof-extends/src/early_intercept.rs +++ b/seed/cli/nullable-allof-extends/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/nullable-allof-extends/src/error.rs b/seed/cli/nullable-allof-extends/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/nullable-allof-extends/src/error.rs +++ b/seed/cli/nullable-allof-extends/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/nullable-allof-extends/src/formatter.rs b/seed/cli/nullable-allof-extends/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/nullable-allof-extends/src/formatter.rs +++ b/seed/cli/nullable-allof-extends/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/nullable-allof-extends/src/graphql/app.rs b/seed/cli/nullable-allof-extends/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/nullable-allof-extends/src/graphql/app.rs +++ b/seed/cli/nullable-allof-extends/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/nullable-allof-extends/src/graphql/binding.rs b/seed/cli/nullable-allof-extends/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/nullable-allof-extends/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/nullable-allof-extends/src/graphql/commands.rs b/seed/cli/nullable-allof-extends/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/nullable-allof-extends/src/graphql/commands.rs +++ b/seed/cli/nullable-allof-extends/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/nullable-allof-extends/src/graphql/mod.rs b/seed/cli/nullable-allof-extends/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/nullable-allof-extends/src/graphql/mod.rs +++ b/seed/cli/nullable-allof-extends/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/nullable-allof-extends/src/hooks.rs b/seed/cli/nullable-allof-extends/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/nullable-allof-extends/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/seed/cli/nullable-allof-extends/src/lib.rs b/seed/cli/nullable-allof-extends/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/nullable-allof-extends/src/lib.rs +++ b/seed/cli/nullable-allof-extends/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/nullable-allof-extends/src/logging.rs b/seed/cli/nullable-allof-extends/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/nullable-allof-extends/src/logging.rs +++ b/seed/cli/nullable-allof-extends/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/nullable-allof-extends/src/man.rs b/seed/cli/nullable-allof-extends/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/nullable-allof-extends/src/man.rs +++ b/seed/cli/nullable-allof-extends/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/nullable-allof-extends/src/openapi/__fixtures__/openapi.json b/seed/cli/nullable-allof-extends/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/nullable-allof-extends/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/nullable-allof-extends/src/openapi/app.rs b/seed/cli/nullable-allof-extends/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/nullable-allof-extends/src/openapi/app.rs +++ b/seed/cli/nullable-allof-extends/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/nullable-allof-extends/src/openapi/binding.rs b/seed/cli/nullable-allof-extends/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/nullable-allof-extends/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/nullable-allof-extends/src/openapi/commands.rs b/seed/cli/nullable-allof-extends/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/nullable-allof-extends/src/openapi/commands.rs +++ b/seed/cli/nullable-allof-extends/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/nullable-allof-extends/src/openapi/discovery.rs b/seed/cli/nullable-allof-extends/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/nullable-allof-extends/src/openapi/discovery.rs +++ b/seed/cli/nullable-allof-extends/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/nullable-allof-extends/src/openapi/executor.rs b/seed/cli/nullable-allof-extends/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/nullable-allof-extends/src/openapi/executor.rs +++ b/seed/cli/nullable-allof-extends/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/nullable-allof-extends/src/openapi/help.rs b/seed/cli/nullable-allof-extends/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/nullable-allof-extends/src/openapi/help.rs +++ b/seed/cli/nullable-allof-extends/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/nullable-allof-extends/src/openapi/mod.rs b/seed/cli/nullable-allof-extends/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/nullable-allof-extends/src/openapi/mod.rs +++ b/seed/cli/nullable-allof-extends/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/nullable-allof-extends/src/openapi/overlay.rs b/seed/cli/nullable-allof-extends/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/nullable-allof-extends/src/openapi/overlay.rs +++ b/seed/cli/nullable-allof-extends/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/nullable-allof-extends/src/openapi/parser.rs b/seed/cli/nullable-allof-extends/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/nullable-allof-extends/src/openapi/parser.rs +++ b/seed/cli/nullable-allof-extends/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/nullable-allof-extends/src/openapi/skill_emitter.rs b/seed/cli/nullable-allof-extends/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/nullable-allof-extends/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/nullable-allof-extends/src/stability.rs b/seed/cli/nullable-allof-extends/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/nullable-allof-extends/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/seed/cli/nullable-allof-extends/tests/auth_routing_wire.rs b/seed/cli/nullable-allof-extends/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/nullable-allof-extends/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/nullable-allof-extends/tests/common/mod.rs b/seed/cli/nullable-allof-extends/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/nullable-allof-extends/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/nullable-allof-extends/tests/lib_api.rs b/seed/cli/nullable-allof-extends/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/nullable-allof-extends/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/nullable-allof-extends/tests/openapi_streaming_wire.rs b/seed/cli/nullable-allof-extends/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/nullable-allof-extends/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/nullable-allof-extends/tests/tls_env_vars.rs b/seed/cli/nullable-allof-extends/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/nullable-allof-extends/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/nullable-allof-extends/tests/websocket_wire.rs b/seed/cli/nullable-allof-extends/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/nullable-allof-extends/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/nullable-allof-extends/tests/x_name_server_alias_wire.rs b/seed/cli/nullable-allof-extends/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/nullable-allof-extends/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/nullable-request-body/.github/workflows/ci.yml b/seed/cli/nullable-request-body/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/nullable-request-body/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/nullable-request-body/.github/workflows/release.yml b/seed/cli/nullable-request-body/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/nullable-request-body/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/nullable-request-body/Cargo.lock b/seed/cli/nullable-request-body/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/nullable-request-body/Cargo.lock +++ b/seed/cli/nullable-request-body/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/nullable-request-body/Cargo.toml b/seed/cli/nullable-request-body/Cargo.toml index 3173f130532a..0a453b5adcd1 100644 --- a/seed/cli/nullable-request-body/Cargo.toml +++ b/seed/cli/nullable-request-body/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "nullable-request-body" +path = "cli/nullable-request-body/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/nullable-request-body/cli/nullable-request-body/main.rs b/seed/cli/nullable-request-body/cli/nullable-request-body/main.rs new file mode 100644 index 000000000000..ab509deed79f --- /dev/null +++ b/seed/cli/nullable-request-body/cli/nullable-request-body/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("nullable-request-body") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/nullable-request-body/cli/openapi-fixture/openapi0.json b/seed/cli/nullable-request-body/cli/nullable-request-body/openapi0.json similarity index 100% rename from seed/cli/nullable-request-body/cli/openapi-fixture/openapi0.json rename to seed/cli/nullable-request-body/cli/nullable-request-body/openapi0.json diff --git a/seed/cli/nullable-request-body/cli/openapi-fixture/main.rs b/seed/cli/nullable-request-body/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/nullable-request-body/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/nullable-request-body/dist-workspace.toml b/seed/cli/nullable-request-body/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/nullable-request-body/dist-workspace.toml +++ b/seed/cli/nullable-request-body/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/nullable-request-body/src/app.rs b/seed/cli/nullable-request-body/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/nullable-request-body/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/nullable-request-body/src/arg_source.rs b/seed/cli/nullable-request-body/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/nullable-request-body/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/seed/cli/nullable-request-body/src/auth/builder.rs b/seed/cli/nullable-request-body/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/nullable-request-body/src/auth/builder.rs +++ b/seed/cli/nullable-request-body/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/nullable-request-body/src/auth/mod.rs b/seed/cli/nullable-request-body/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/nullable-request-body/src/auth/mod.rs +++ b/seed/cli/nullable-request-body/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/nullable-request-body/src/auth/root_builder.rs b/seed/cli/nullable-request-body/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/nullable-request-body/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/nullable-request-body/src/binding.rs b/seed/cli/nullable-request-body/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/nullable-request-body/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/nullable-request-body/src/cli_args.rs b/seed/cli/nullable-request-body/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/nullable-request-body/src/cli_args.rs +++ b/seed/cli/nullable-request-body/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/nullable-request-body/src/completions.rs b/seed/cli/nullable-request-body/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/nullable-request-body/src/completions.rs +++ b/seed/cli/nullable-request-body/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/nullable-request-body/src/custom_commands.rs b/seed/cli/nullable-request-body/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/nullable-request-body/src/custom_commands.rs +++ b/seed/cli/nullable-request-body/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/nullable-request-body/src/early_intercept.rs b/seed/cli/nullable-request-body/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/nullable-request-body/src/early_intercept.rs +++ b/seed/cli/nullable-request-body/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/nullable-request-body/src/error.rs b/seed/cli/nullable-request-body/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/nullable-request-body/src/error.rs +++ b/seed/cli/nullable-request-body/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/nullable-request-body/src/formatter.rs b/seed/cli/nullable-request-body/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/nullable-request-body/src/formatter.rs +++ b/seed/cli/nullable-request-body/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/nullable-request-body/src/graphql/app.rs b/seed/cli/nullable-request-body/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/nullable-request-body/src/graphql/app.rs +++ b/seed/cli/nullable-request-body/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/nullable-request-body/src/graphql/binding.rs b/seed/cli/nullable-request-body/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/nullable-request-body/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/nullable-request-body/src/graphql/commands.rs b/seed/cli/nullable-request-body/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/nullable-request-body/src/graphql/commands.rs +++ b/seed/cli/nullable-request-body/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/nullable-request-body/src/graphql/mod.rs b/seed/cli/nullable-request-body/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/nullable-request-body/src/graphql/mod.rs +++ b/seed/cli/nullable-request-body/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/nullable-request-body/src/hooks.rs b/seed/cli/nullable-request-body/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/nullable-request-body/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/seed/cli/nullable-request-body/src/lib.rs b/seed/cli/nullable-request-body/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/nullable-request-body/src/lib.rs +++ b/seed/cli/nullable-request-body/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/nullable-request-body/src/logging.rs b/seed/cli/nullable-request-body/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/nullable-request-body/src/logging.rs +++ b/seed/cli/nullable-request-body/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/nullable-request-body/src/man.rs b/seed/cli/nullable-request-body/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/nullable-request-body/src/man.rs +++ b/seed/cli/nullable-request-body/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/nullable-request-body/src/openapi/__fixtures__/openapi.json b/seed/cli/nullable-request-body/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/nullable-request-body/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/nullable-request-body/src/openapi/app.rs b/seed/cli/nullable-request-body/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/nullable-request-body/src/openapi/app.rs +++ b/seed/cli/nullable-request-body/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/nullable-request-body/src/openapi/binding.rs b/seed/cli/nullable-request-body/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/nullable-request-body/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/nullable-request-body/src/openapi/commands.rs b/seed/cli/nullable-request-body/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/nullable-request-body/src/openapi/commands.rs +++ b/seed/cli/nullable-request-body/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/nullable-request-body/src/openapi/discovery.rs b/seed/cli/nullable-request-body/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/nullable-request-body/src/openapi/discovery.rs +++ b/seed/cli/nullable-request-body/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/nullable-request-body/src/openapi/executor.rs b/seed/cli/nullable-request-body/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/nullable-request-body/src/openapi/executor.rs +++ b/seed/cli/nullable-request-body/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/nullable-request-body/src/openapi/help.rs b/seed/cli/nullable-request-body/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/nullable-request-body/src/openapi/help.rs +++ b/seed/cli/nullable-request-body/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/nullable-request-body/src/openapi/mod.rs b/seed/cli/nullable-request-body/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/nullable-request-body/src/openapi/mod.rs +++ b/seed/cli/nullable-request-body/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/nullable-request-body/src/openapi/overlay.rs b/seed/cli/nullable-request-body/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/nullable-request-body/src/openapi/overlay.rs +++ b/seed/cli/nullable-request-body/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/nullable-request-body/src/openapi/parser.rs b/seed/cli/nullable-request-body/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/nullable-request-body/src/openapi/parser.rs +++ b/seed/cli/nullable-request-body/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/nullable-request-body/src/openapi/skill_emitter.rs b/seed/cli/nullable-request-body/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/nullable-request-body/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/nullable-request-body/src/stability.rs b/seed/cli/nullable-request-body/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/nullable-request-body/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/seed/cli/nullable-request-body/tests/auth_routing_wire.rs b/seed/cli/nullable-request-body/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/nullable-request-body/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/nullable-request-body/tests/common/mod.rs b/seed/cli/nullable-request-body/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/nullable-request-body/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/nullable-request-body/tests/lib_api.rs b/seed/cli/nullable-request-body/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/nullable-request-body/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/nullable-request-body/tests/openapi_streaming_wire.rs b/seed/cli/nullable-request-body/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/nullable-request-body/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/nullable-request-body/tests/tls_env_vars.rs b/seed/cli/nullable-request-body/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/nullable-request-body/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/nullable-request-body/tests/websocket_wire.rs b/seed/cli/nullable-request-body/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/nullable-request-body/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/nullable-request-body/tests/x_name_server_alias_wire.rs b/seed/cli/nullable-request-body/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/nullable-request-body/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/oauth-client-credentials-openapi/.github/workflows/ci.yml b/seed/cli/oauth-client-credentials-openapi/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/oauth-client-credentials-openapi/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/oauth-client-credentials-openapi/.github/workflows/release.yml b/seed/cli/oauth-client-credentials-openapi/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/oauth-client-credentials-openapi/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/oauth-client-credentials-openapi/Cargo.lock b/seed/cli/oauth-client-credentials-openapi/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/oauth-client-credentials-openapi/Cargo.lock +++ b/seed/cli/oauth-client-credentials-openapi/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/oauth-client-credentials-openapi/Cargo.toml b/seed/cli/oauth-client-credentials-openapi/Cargo.toml index 3173f130532a..2970a2601486 100644 --- a/seed/cli/oauth-client-credentials-openapi/Cargo.toml +++ b/seed/cli/oauth-client-credentials-openapi/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "oauth-client-credentials-openapi" +path = "cli/oauth-client-credentials-openapi/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/oauth-client-credentials-openapi/cli/oauth-client-credentials-openapi/main.rs b/seed/cli/oauth-client-credentials-openapi/cli/oauth-client-credentials-openapi/main.rs new file mode 100644 index 000000000000..a2bd1e08705c --- /dev/null +++ b/seed/cli/oauth-client-credentials-openapi/cli/oauth-client-credentials-openapi/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("oauth-client-credentials-openapi") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/oauth-client-credentials-openapi/cli/openapi-fixture/openapi0.json b/seed/cli/oauth-client-credentials-openapi/cli/oauth-client-credentials-openapi/openapi0.json similarity index 100% rename from seed/cli/oauth-client-credentials-openapi/cli/openapi-fixture/openapi0.json rename to seed/cli/oauth-client-credentials-openapi/cli/oauth-client-credentials-openapi/openapi0.json diff --git a/seed/cli/oauth-client-credentials-openapi/cli/openapi-fixture/main.rs b/seed/cli/oauth-client-credentials-openapi/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/oauth-client-credentials-openapi/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/oauth-client-credentials-openapi/dist-workspace.toml b/seed/cli/oauth-client-credentials-openapi/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/oauth-client-credentials-openapi/dist-workspace.toml +++ b/seed/cli/oauth-client-credentials-openapi/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/oauth-client-credentials-openapi/src/app.rs b/seed/cli/oauth-client-credentials-openapi/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/oauth-client-credentials-openapi/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/oauth-client-credentials-openapi/src/arg_source.rs b/seed/cli/oauth-client-credentials-openapi/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/oauth-client-credentials-openapi/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/seed/cli/oauth-client-credentials-openapi/src/auth/builder.rs b/seed/cli/oauth-client-credentials-openapi/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/auth/builder.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/oauth-client-credentials-openapi/src/auth/mod.rs b/seed/cli/oauth-client-credentials-openapi/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/auth/mod.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/oauth-client-credentials-openapi/src/auth/root_builder.rs b/seed/cli/oauth-client-credentials-openapi/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/oauth-client-credentials-openapi/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/oauth-client-credentials-openapi/src/binding.rs b/seed/cli/oauth-client-credentials-openapi/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/oauth-client-credentials-openapi/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/oauth-client-credentials-openapi/src/cli_args.rs b/seed/cli/oauth-client-credentials-openapi/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/cli_args.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/oauth-client-credentials-openapi/src/completions.rs b/seed/cli/oauth-client-credentials-openapi/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/completions.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/oauth-client-credentials-openapi/src/custom_commands.rs b/seed/cli/oauth-client-credentials-openapi/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/custom_commands.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/oauth-client-credentials-openapi/src/early_intercept.rs b/seed/cli/oauth-client-credentials-openapi/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/early_intercept.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/oauth-client-credentials-openapi/src/error.rs b/seed/cli/oauth-client-credentials-openapi/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/error.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/oauth-client-credentials-openapi/src/formatter.rs b/seed/cli/oauth-client-credentials-openapi/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/formatter.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/oauth-client-credentials-openapi/src/graphql/app.rs b/seed/cli/oauth-client-credentials-openapi/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/graphql/app.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/oauth-client-credentials-openapi/src/graphql/binding.rs b/seed/cli/oauth-client-credentials-openapi/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/oauth-client-credentials-openapi/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/oauth-client-credentials-openapi/src/graphql/commands.rs b/seed/cli/oauth-client-credentials-openapi/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/graphql/commands.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/oauth-client-credentials-openapi/src/graphql/mod.rs b/seed/cli/oauth-client-credentials-openapi/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/graphql/mod.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/oauth-client-credentials-openapi/src/hooks.rs b/seed/cli/oauth-client-credentials-openapi/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/oauth-client-credentials-openapi/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/seed/cli/oauth-client-credentials-openapi/src/lib.rs b/seed/cli/oauth-client-credentials-openapi/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/lib.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/oauth-client-credentials-openapi/src/logging.rs b/seed/cli/oauth-client-credentials-openapi/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/logging.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/oauth-client-credentials-openapi/src/man.rs b/seed/cli/oauth-client-credentials-openapi/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/man.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/oauth-client-credentials-openapi/src/openapi/__fixtures__/openapi.json b/seed/cli/oauth-client-credentials-openapi/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/oauth-client-credentials-openapi/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/oauth-client-credentials-openapi/src/openapi/app.rs b/seed/cli/oauth-client-credentials-openapi/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/openapi/app.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/oauth-client-credentials-openapi/src/openapi/binding.rs b/seed/cli/oauth-client-credentials-openapi/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/oauth-client-credentials-openapi/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/oauth-client-credentials-openapi/src/openapi/commands.rs b/seed/cli/oauth-client-credentials-openapi/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/openapi/commands.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/oauth-client-credentials-openapi/src/openapi/discovery.rs b/seed/cli/oauth-client-credentials-openapi/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/openapi/discovery.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/oauth-client-credentials-openapi/src/openapi/executor.rs b/seed/cli/oauth-client-credentials-openapi/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/openapi/executor.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/oauth-client-credentials-openapi/src/openapi/help.rs b/seed/cli/oauth-client-credentials-openapi/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/openapi/help.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/oauth-client-credentials-openapi/src/openapi/mod.rs b/seed/cli/oauth-client-credentials-openapi/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/openapi/mod.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/oauth-client-credentials-openapi/src/openapi/overlay.rs b/seed/cli/oauth-client-credentials-openapi/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/openapi/overlay.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/oauth-client-credentials-openapi/src/openapi/parser.rs b/seed/cli/oauth-client-credentials-openapi/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/oauth-client-credentials-openapi/src/openapi/parser.rs +++ b/seed/cli/oauth-client-credentials-openapi/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/oauth-client-credentials-openapi/src/openapi/skill_emitter.rs b/seed/cli/oauth-client-credentials-openapi/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/oauth-client-credentials-openapi/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/oauth-client-credentials-openapi/src/stability.rs b/seed/cli/oauth-client-credentials-openapi/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/oauth-client-credentials-openapi/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/seed/cli/oauth-client-credentials-openapi/tests/auth_routing_wire.rs b/seed/cli/oauth-client-credentials-openapi/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/oauth-client-credentials-openapi/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/oauth-client-credentials-openapi/tests/common/mod.rs b/seed/cli/oauth-client-credentials-openapi/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/oauth-client-credentials-openapi/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/oauth-client-credentials-openapi/tests/lib_api.rs b/seed/cli/oauth-client-credentials-openapi/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/oauth-client-credentials-openapi/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/oauth-client-credentials-openapi/tests/openapi_streaming_wire.rs b/seed/cli/oauth-client-credentials-openapi/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/oauth-client-credentials-openapi/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/oauth-client-credentials-openapi/tests/tls_env_vars.rs b/seed/cli/oauth-client-credentials-openapi/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/oauth-client-credentials-openapi/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/oauth-client-credentials-openapi/tests/websocket_wire.rs b/seed/cli/oauth-client-credentials-openapi/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/oauth-client-credentials-openapi/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/oauth-client-credentials-openapi/tests/x_name_server_alias_wire.rs b/seed/cli/oauth-client-credentials-openapi/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/oauth-client-credentials-openapi/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/openapi-request-body-ref/.github/workflows/ci.yml b/seed/cli/openapi-request-body-ref/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/openapi-request-body-ref/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/openapi-request-body-ref/.github/workflows/release.yml b/seed/cli/openapi-request-body-ref/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/openapi-request-body-ref/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/openapi-request-body-ref/Cargo.lock b/seed/cli/openapi-request-body-ref/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/openapi-request-body-ref/Cargo.lock +++ b/seed/cli/openapi-request-body-ref/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/openapi-request-body-ref/Cargo.toml b/seed/cli/openapi-request-body-ref/Cargo.toml index 3173f130532a..fb433c421e4b 100644 --- a/seed/cli/openapi-request-body-ref/Cargo.toml +++ b/seed/cli/openapi-request-body-ref/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "openapi-request-body-ref" +path = "cli/openapi-request-body-ref/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/openapi-request-body-ref/cli/openapi-fixture/main.rs b/seed/cli/openapi-request-body-ref/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/openapi-request-body-ref/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/openapi-request-body-ref/cli/openapi-request-body-ref/main.rs b/seed/cli/openapi-request-body-ref/cli/openapi-request-body-ref/main.rs new file mode 100644 index 000000000000..db0012dc2629 --- /dev/null +++ b/seed/cli/openapi-request-body-ref/cli/openapi-request-body-ref/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("openapi-request-body-ref") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/openapi-request-body-ref/cli/openapi-fixture/openapi0.json b/seed/cli/openapi-request-body-ref/cli/openapi-request-body-ref/openapi0.json similarity index 100% rename from seed/cli/openapi-request-body-ref/cli/openapi-fixture/openapi0.json rename to seed/cli/openapi-request-body-ref/cli/openapi-request-body-ref/openapi0.json diff --git a/seed/cli/openapi-request-body-ref/dist-workspace.toml b/seed/cli/openapi-request-body-ref/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/openapi-request-body-ref/dist-workspace.toml +++ b/seed/cli/openapi-request-body-ref/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/openapi-request-body-ref/src/app.rs b/seed/cli/openapi-request-body-ref/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/openapi-request-body-ref/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/openapi-request-body-ref/src/arg_source.rs b/seed/cli/openapi-request-body-ref/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/openapi-request-body-ref/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/seed/cli/openapi-request-body-ref/src/auth/builder.rs b/seed/cli/openapi-request-body-ref/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/openapi-request-body-ref/src/auth/builder.rs +++ b/seed/cli/openapi-request-body-ref/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/openapi-request-body-ref/src/auth/mod.rs b/seed/cli/openapi-request-body-ref/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/openapi-request-body-ref/src/auth/mod.rs +++ b/seed/cli/openapi-request-body-ref/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/openapi-request-body-ref/src/auth/root_builder.rs b/seed/cli/openapi-request-body-ref/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/openapi-request-body-ref/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/openapi-request-body-ref/src/binding.rs b/seed/cli/openapi-request-body-ref/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/openapi-request-body-ref/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/openapi-request-body-ref/src/cli_args.rs b/seed/cli/openapi-request-body-ref/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/openapi-request-body-ref/src/cli_args.rs +++ b/seed/cli/openapi-request-body-ref/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/openapi-request-body-ref/src/completions.rs b/seed/cli/openapi-request-body-ref/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/openapi-request-body-ref/src/completions.rs +++ b/seed/cli/openapi-request-body-ref/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/openapi-request-body-ref/src/custom_commands.rs b/seed/cli/openapi-request-body-ref/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/openapi-request-body-ref/src/custom_commands.rs +++ b/seed/cli/openapi-request-body-ref/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/openapi-request-body-ref/src/early_intercept.rs b/seed/cli/openapi-request-body-ref/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/openapi-request-body-ref/src/early_intercept.rs +++ b/seed/cli/openapi-request-body-ref/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/openapi-request-body-ref/src/error.rs b/seed/cli/openapi-request-body-ref/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/openapi-request-body-ref/src/error.rs +++ b/seed/cli/openapi-request-body-ref/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/openapi-request-body-ref/src/formatter.rs b/seed/cli/openapi-request-body-ref/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/openapi-request-body-ref/src/formatter.rs +++ b/seed/cli/openapi-request-body-ref/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/openapi-request-body-ref/src/graphql/app.rs b/seed/cli/openapi-request-body-ref/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/openapi-request-body-ref/src/graphql/app.rs +++ b/seed/cli/openapi-request-body-ref/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/openapi-request-body-ref/src/graphql/binding.rs b/seed/cli/openapi-request-body-ref/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/openapi-request-body-ref/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/openapi-request-body-ref/src/graphql/commands.rs b/seed/cli/openapi-request-body-ref/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/openapi-request-body-ref/src/graphql/commands.rs +++ b/seed/cli/openapi-request-body-ref/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/openapi-request-body-ref/src/graphql/mod.rs b/seed/cli/openapi-request-body-ref/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/openapi-request-body-ref/src/graphql/mod.rs +++ b/seed/cli/openapi-request-body-ref/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/openapi-request-body-ref/src/hooks.rs b/seed/cli/openapi-request-body-ref/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/openapi-request-body-ref/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/seed/cli/openapi-request-body-ref/src/lib.rs b/seed/cli/openapi-request-body-ref/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/openapi-request-body-ref/src/lib.rs +++ b/seed/cli/openapi-request-body-ref/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/openapi-request-body-ref/src/logging.rs b/seed/cli/openapi-request-body-ref/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/openapi-request-body-ref/src/logging.rs +++ b/seed/cli/openapi-request-body-ref/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/openapi-request-body-ref/src/man.rs b/seed/cli/openapi-request-body-ref/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/openapi-request-body-ref/src/man.rs +++ b/seed/cli/openapi-request-body-ref/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/openapi-request-body-ref/src/openapi/__fixtures__/openapi.json b/seed/cli/openapi-request-body-ref/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/openapi-request-body-ref/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/openapi-request-body-ref/src/openapi/app.rs b/seed/cli/openapi-request-body-ref/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/openapi-request-body-ref/src/openapi/app.rs +++ b/seed/cli/openapi-request-body-ref/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/openapi-request-body-ref/src/openapi/binding.rs b/seed/cli/openapi-request-body-ref/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/openapi-request-body-ref/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/openapi-request-body-ref/src/openapi/commands.rs b/seed/cli/openapi-request-body-ref/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/openapi-request-body-ref/src/openapi/commands.rs +++ b/seed/cli/openapi-request-body-ref/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/openapi-request-body-ref/src/openapi/discovery.rs b/seed/cli/openapi-request-body-ref/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/openapi-request-body-ref/src/openapi/discovery.rs +++ b/seed/cli/openapi-request-body-ref/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/openapi-request-body-ref/src/openapi/executor.rs b/seed/cli/openapi-request-body-ref/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/openapi-request-body-ref/src/openapi/executor.rs +++ b/seed/cli/openapi-request-body-ref/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/openapi-request-body-ref/src/openapi/help.rs b/seed/cli/openapi-request-body-ref/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/openapi-request-body-ref/src/openapi/help.rs +++ b/seed/cli/openapi-request-body-ref/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/openapi-request-body-ref/src/openapi/mod.rs b/seed/cli/openapi-request-body-ref/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/openapi-request-body-ref/src/openapi/mod.rs +++ b/seed/cli/openapi-request-body-ref/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/openapi-request-body-ref/src/openapi/overlay.rs b/seed/cli/openapi-request-body-ref/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/openapi-request-body-ref/src/openapi/overlay.rs +++ b/seed/cli/openapi-request-body-ref/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/openapi-request-body-ref/src/openapi/parser.rs b/seed/cli/openapi-request-body-ref/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/openapi-request-body-ref/src/openapi/parser.rs +++ b/seed/cli/openapi-request-body-ref/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/openapi-request-body-ref/src/openapi/skill_emitter.rs b/seed/cli/openapi-request-body-ref/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/openapi-request-body-ref/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/openapi-request-body-ref/src/stability.rs b/seed/cli/openapi-request-body-ref/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/openapi-request-body-ref/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/seed/cli/openapi-request-body-ref/tests/auth_routing_wire.rs b/seed/cli/openapi-request-body-ref/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/openapi-request-body-ref/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/openapi-request-body-ref/tests/common/mod.rs b/seed/cli/openapi-request-body-ref/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/openapi-request-body-ref/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/openapi-request-body-ref/tests/lib_api.rs b/seed/cli/openapi-request-body-ref/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/openapi-request-body-ref/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/openapi-request-body-ref/tests/openapi_streaming_wire.rs b/seed/cli/openapi-request-body-ref/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/openapi-request-body-ref/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/openapi-request-body-ref/tests/tls_env_vars.rs b/seed/cli/openapi-request-body-ref/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/openapi-request-body-ref/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/openapi-request-body-ref/tests/websocket_wire.rs b/seed/cli/openapi-request-body-ref/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/openapi-request-body-ref/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/openapi-request-body-ref/tests/x_name_server_alias_wire.rs b/seed/cli/openapi-request-body-ref/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/openapi-request-body-ref/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/query-param-name-conflict/.github/workflows/ci.yml b/seed/cli/query-param-name-conflict/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/query-param-name-conflict/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/query-param-name-conflict/.github/workflows/release.yml b/seed/cli/query-param-name-conflict/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/query-param-name-conflict/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/query-param-name-conflict/Cargo.lock b/seed/cli/query-param-name-conflict/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/query-param-name-conflict/Cargo.lock +++ b/seed/cli/query-param-name-conflict/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/query-param-name-conflict/Cargo.toml b/seed/cli/query-param-name-conflict/Cargo.toml index 3173f130532a..1901ceed7942 100644 --- a/seed/cli/query-param-name-conflict/Cargo.toml +++ b/seed/cli/query-param-name-conflict/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "query-param-name-conflict-api" +path = "cli/query-param-name-conflict-api/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/query-param-name-conflict/cli/openapi-fixture/main.rs b/seed/cli/query-param-name-conflict/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/query-param-name-conflict/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/query-param-name-conflict/cli/query-param-name-conflict-api/main.rs b/seed/cli/query-param-name-conflict/cli/query-param-name-conflict-api/main.rs new file mode 100644 index 000000000000..89a3ff23601a --- /dev/null +++ b/seed/cli/query-param-name-conflict/cli/query-param-name-conflict-api/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("query-param-name-conflict-api") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/query-param-name-conflict/cli/openapi-fixture/openapi0.json b/seed/cli/query-param-name-conflict/cli/query-param-name-conflict-api/openapi0.json similarity index 100% rename from seed/cli/query-param-name-conflict/cli/openapi-fixture/openapi0.json rename to seed/cli/query-param-name-conflict/cli/query-param-name-conflict-api/openapi0.json diff --git a/seed/cli/query-param-name-conflict/dist-workspace.toml b/seed/cli/query-param-name-conflict/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/query-param-name-conflict/dist-workspace.toml +++ b/seed/cli/query-param-name-conflict/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/query-param-name-conflict/src/app.rs b/seed/cli/query-param-name-conflict/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/query-param-name-conflict/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/query-param-name-conflict/src/arg_source.rs b/seed/cli/query-param-name-conflict/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/query-param-name-conflict/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/seed/cli/query-param-name-conflict/src/auth/builder.rs b/seed/cli/query-param-name-conflict/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/query-param-name-conflict/src/auth/builder.rs +++ b/seed/cli/query-param-name-conflict/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/query-param-name-conflict/src/auth/mod.rs b/seed/cli/query-param-name-conflict/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/query-param-name-conflict/src/auth/mod.rs +++ b/seed/cli/query-param-name-conflict/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/query-param-name-conflict/src/auth/root_builder.rs b/seed/cli/query-param-name-conflict/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/query-param-name-conflict/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/query-param-name-conflict/src/binding.rs b/seed/cli/query-param-name-conflict/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/query-param-name-conflict/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/query-param-name-conflict/src/cli_args.rs b/seed/cli/query-param-name-conflict/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/query-param-name-conflict/src/cli_args.rs +++ b/seed/cli/query-param-name-conflict/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/query-param-name-conflict/src/completions.rs b/seed/cli/query-param-name-conflict/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/query-param-name-conflict/src/completions.rs +++ b/seed/cli/query-param-name-conflict/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/query-param-name-conflict/src/custom_commands.rs b/seed/cli/query-param-name-conflict/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/query-param-name-conflict/src/custom_commands.rs +++ b/seed/cli/query-param-name-conflict/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/query-param-name-conflict/src/early_intercept.rs b/seed/cli/query-param-name-conflict/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/query-param-name-conflict/src/early_intercept.rs +++ b/seed/cli/query-param-name-conflict/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/query-param-name-conflict/src/error.rs b/seed/cli/query-param-name-conflict/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/query-param-name-conflict/src/error.rs +++ b/seed/cli/query-param-name-conflict/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/query-param-name-conflict/src/formatter.rs b/seed/cli/query-param-name-conflict/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/query-param-name-conflict/src/formatter.rs +++ b/seed/cli/query-param-name-conflict/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/query-param-name-conflict/src/graphql/app.rs b/seed/cli/query-param-name-conflict/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/query-param-name-conflict/src/graphql/app.rs +++ b/seed/cli/query-param-name-conflict/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/query-param-name-conflict/src/graphql/binding.rs b/seed/cli/query-param-name-conflict/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/query-param-name-conflict/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/query-param-name-conflict/src/graphql/commands.rs b/seed/cli/query-param-name-conflict/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/query-param-name-conflict/src/graphql/commands.rs +++ b/seed/cli/query-param-name-conflict/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/query-param-name-conflict/src/graphql/mod.rs b/seed/cli/query-param-name-conflict/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/query-param-name-conflict/src/graphql/mod.rs +++ b/seed/cli/query-param-name-conflict/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/query-param-name-conflict/src/hooks.rs b/seed/cli/query-param-name-conflict/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/query-param-name-conflict/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/seed/cli/query-param-name-conflict/src/lib.rs b/seed/cli/query-param-name-conflict/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/query-param-name-conflict/src/lib.rs +++ b/seed/cli/query-param-name-conflict/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/query-param-name-conflict/src/logging.rs b/seed/cli/query-param-name-conflict/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/query-param-name-conflict/src/logging.rs +++ b/seed/cli/query-param-name-conflict/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/query-param-name-conflict/src/man.rs b/seed/cli/query-param-name-conflict/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/query-param-name-conflict/src/man.rs +++ b/seed/cli/query-param-name-conflict/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/query-param-name-conflict/src/openapi/__fixtures__/openapi.json b/seed/cli/query-param-name-conflict/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/query-param-name-conflict/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/query-param-name-conflict/src/openapi/app.rs b/seed/cli/query-param-name-conflict/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/query-param-name-conflict/src/openapi/app.rs +++ b/seed/cli/query-param-name-conflict/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/query-param-name-conflict/src/openapi/binding.rs b/seed/cli/query-param-name-conflict/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/query-param-name-conflict/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/query-param-name-conflict/src/openapi/commands.rs b/seed/cli/query-param-name-conflict/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/query-param-name-conflict/src/openapi/commands.rs +++ b/seed/cli/query-param-name-conflict/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/query-param-name-conflict/src/openapi/discovery.rs b/seed/cli/query-param-name-conflict/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/query-param-name-conflict/src/openapi/discovery.rs +++ b/seed/cli/query-param-name-conflict/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/query-param-name-conflict/src/openapi/executor.rs b/seed/cli/query-param-name-conflict/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/query-param-name-conflict/src/openapi/executor.rs +++ b/seed/cli/query-param-name-conflict/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/query-param-name-conflict/src/openapi/help.rs b/seed/cli/query-param-name-conflict/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/query-param-name-conflict/src/openapi/help.rs +++ b/seed/cli/query-param-name-conflict/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/query-param-name-conflict/src/openapi/mod.rs b/seed/cli/query-param-name-conflict/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/query-param-name-conflict/src/openapi/mod.rs +++ b/seed/cli/query-param-name-conflict/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/query-param-name-conflict/src/openapi/overlay.rs b/seed/cli/query-param-name-conflict/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/query-param-name-conflict/src/openapi/overlay.rs +++ b/seed/cli/query-param-name-conflict/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/query-param-name-conflict/src/openapi/parser.rs b/seed/cli/query-param-name-conflict/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/query-param-name-conflict/src/openapi/parser.rs +++ b/seed/cli/query-param-name-conflict/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/query-param-name-conflict/src/openapi/skill_emitter.rs b/seed/cli/query-param-name-conflict/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/query-param-name-conflict/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/query-param-name-conflict/src/stability.rs b/seed/cli/query-param-name-conflict/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/query-param-name-conflict/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/seed/cli/query-param-name-conflict/tests/auth_routing_wire.rs b/seed/cli/query-param-name-conflict/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/query-param-name-conflict/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/query-param-name-conflict/tests/common/mod.rs b/seed/cli/query-param-name-conflict/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/query-param-name-conflict/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/query-param-name-conflict/tests/lib_api.rs b/seed/cli/query-param-name-conflict/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/query-param-name-conflict/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/query-param-name-conflict/tests/openapi_streaming_wire.rs b/seed/cli/query-param-name-conflict/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/query-param-name-conflict/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/query-param-name-conflict/tests/tls_env_vars.rs b/seed/cli/query-param-name-conflict/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/query-param-name-conflict/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/query-param-name-conflict/tests/websocket_wire.rs b/seed/cli/query-param-name-conflict/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/query-param-name-conflict/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/query-param-name-conflict/tests/x_name_server_alias_wire.rs b/seed/cli/query-param-name-conflict/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/query-param-name-conflict/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/query-parameters-openapi-as-objects/.github/workflows/ci.yml b/seed/cli/query-parameters-openapi-as-objects/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/query-parameters-openapi-as-objects/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/query-parameters-openapi-as-objects/.github/workflows/release.yml b/seed/cli/query-parameters-openapi-as-objects/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/query-parameters-openapi-as-objects/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/query-parameters-openapi-as-objects/Cargo.lock b/seed/cli/query-parameters-openapi-as-objects/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/query-parameters-openapi-as-objects/Cargo.lock +++ b/seed/cli/query-parameters-openapi-as-objects/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/query-parameters-openapi-as-objects/Cargo.toml b/seed/cli/query-parameters-openapi-as-objects/Cargo.toml index 3173f130532a..5d3bcf79c003 100644 --- a/seed/cli/query-parameters-openapi-as-objects/Cargo.toml +++ b/seed/cli/query-parameters-openapi-as-objects/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "query-parameters-api" +path = "cli/query-parameters-api/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/query-parameters-openapi-as-objects/cli/openapi-fixture/main.rs b/seed/cli/query-parameters-openapi-as-objects/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/query-parameters-openapi-as-objects/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/query-parameters-openapi-as-objects/cli/query-parameters-api/main.rs b/seed/cli/query-parameters-openapi-as-objects/cli/query-parameters-api/main.rs new file mode 100644 index 000000000000..255a5e98adfe --- /dev/null +++ b/seed/cli/query-parameters-openapi-as-objects/cli/query-parameters-api/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("query-parameters-api") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/query-parameters-openapi-as-objects/cli/openapi-fixture/openapi0.json b/seed/cli/query-parameters-openapi-as-objects/cli/query-parameters-api/openapi0.json similarity index 100% rename from seed/cli/query-parameters-openapi-as-objects/cli/openapi-fixture/openapi0.json rename to seed/cli/query-parameters-openapi-as-objects/cli/query-parameters-api/openapi0.json diff --git a/seed/cli/query-parameters-openapi-as-objects/dist-workspace.toml b/seed/cli/query-parameters-openapi-as-objects/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/query-parameters-openapi-as-objects/dist-workspace.toml +++ b/seed/cli/query-parameters-openapi-as-objects/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/query-parameters-openapi-as-objects/src/app.rs b/seed/cli/query-parameters-openapi-as-objects/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/query-parameters-openapi-as-objects/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/query-parameters-openapi-as-objects/src/arg_source.rs b/seed/cli/query-parameters-openapi-as-objects/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/query-parameters-openapi-as-objects/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/seed/cli/query-parameters-openapi-as-objects/src/auth/builder.rs b/seed/cli/query-parameters-openapi-as-objects/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/auth/builder.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/query-parameters-openapi-as-objects/src/auth/mod.rs b/seed/cli/query-parameters-openapi-as-objects/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/auth/mod.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/query-parameters-openapi-as-objects/src/auth/root_builder.rs b/seed/cli/query-parameters-openapi-as-objects/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/query-parameters-openapi-as-objects/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/query-parameters-openapi-as-objects/src/binding.rs b/seed/cli/query-parameters-openapi-as-objects/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/query-parameters-openapi-as-objects/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/query-parameters-openapi-as-objects/src/cli_args.rs b/seed/cli/query-parameters-openapi-as-objects/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/cli_args.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/query-parameters-openapi-as-objects/src/completions.rs b/seed/cli/query-parameters-openapi-as-objects/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/completions.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/query-parameters-openapi-as-objects/src/custom_commands.rs b/seed/cli/query-parameters-openapi-as-objects/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/custom_commands.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/query-parameters-openapi-as-objects/src/early_intercept.rs b/seed/cli/query-parameters-openapi-as-objects/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/early_intercept.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/query-parameters-openapi-as-objects/src/error.rs b/seed/cli/query-parameters-openapi-as-objects/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/error.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/query-parameters-openapi-as-objects/src/formatter.rs b/seed/cli/query-parameters-openapi-as-objects/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/formatter.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/query-parameters-openapi-as-objects/src/graphql/app.rs b/seed/cli/query-parameters-openapi-as-objects/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/graphql/app.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/query-parameters-openapi-as-objects/src/graphql/binding.rs b/seed/cli/query-parameters-openapi-as-objects/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/query-parameters-openapi-as-objects/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/query-parameters-openapi-as-objects/src/graphql/commands.rs b/seed/cli/query-parameters-openapi-as-objects/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/graphql/commands.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/query-parameters-openapi-as-objects/src/graphql/mod.rs b/seed/cli/query-parameters-openapi-as-objects/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/graphql/mod.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/query-parameters-openapi-as-objects/src/hooks.rs b/seed/cli/query-parameters-openapi-as-objects/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/query-parameters-openapi-as-objects/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/seed/cli/query-parameters-openapi-as-objects/src/lib.rs b/seed/cli/query-parameters-openapi-as-objects/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/lib.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/query-parameters-openapi-as-objects/src/logging.rs b/seed/cli/query-parameters-openapi-as-objects/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/logging.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/query-parameters-openapi-as-objects/src/man.rs b/seed/cli/query-parameters-openapi-as-objects/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/man.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/query-parameters-openapi-as-objects/src/openapi/__fixtures__/openapi.json b/seed/cli/query-parameters-openapi-as-objects/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/query-parameters-openapi-as-objects/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/query-parameters-openapi-as-objects/src/openapi/app.rs b/seed/cli/query-parameters-openapi-as-objects/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/openapi/app.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/query-parameters-openapi-as-objects/src/openapi/binding.rs b/seed/cli/query-parameters-openapi-as-objects/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/query-parameters-openapi-as-objects/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/query-parameters-openapi-as-objects/src/openapi/commands.rs b/seed/cli/query-parameters-openapi-as-objects/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/openapi/commands.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/query-parameters-openapi-as-objects/src/openapi/discovery.rs b/seed/cli/query-parameters-openapi-as-objects/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/openapi/discovery.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/query-parameters-openapi-as-objects/src/openapi/executor.rs b/seed/cli/query-parameters-openapi-as-objects/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/openapi/executor.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/query-parameters-openapi-as-objects/src/openapi/help.rs b/seed/cli/query-parameters-openapi-as-objects/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/openapi/help.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/query-parameters-openapi-as-objects/src/openapi/mod.rs b/seed/cli/query-parameters-openapi-as-objects/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/openapi/mod.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/query-parameters-openapi-as-objects/src/openapi/overlay.rs b/seed/cli/query-parameters-openapi-as-objects/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/openapi/overlay.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/query-parameters-openapi-as-objects/src/openapi/parser.rs b/seed/cli/query-parameters-openapi-as-objects/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/query-parameters-openapi-as-objects/src/openapi/parser.rs +++ b/seed/cli/query-parameters-openapi-as-objects/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/query-parameters-openapi-as-objects/src/openapi/skill_emitter.rs b/seed/cli/query-parameters-openapi-as-objects/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/query-parameters-openapi-as-objects/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/query-parameters-openapi-as-objects/src/stability.rs b/seed/cli/query-parameters-openapi-as-objects/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/query-parameters-openapi-as-objects/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/seed/cli/query-parameters-openapi-as-objects/tests/auth_routing_wire.rs b/seed/cli/query-parameters-openapi-as-objects/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/query-parameters-openapi-as-objects/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/query-parameters-openapi-as-objects/tests/common/mod.rs b/seed/cli/query-parameters-openapi-as-objects/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/query-parameters-openapi-as-objects/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/query-parameters-openapi-as-objects/tests/lib_api.rs b/seed/cli/query-parameters-openapi-as-objects/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/query-parameters-openapi-as-objects/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/query-parameters-openapi-as-objects/tests/openapi_streaming_wire.rs b/seed/cli/query-parameters-openapi-as-objects/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/query-parameters-openapi-as-objects/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/query-parameters-openapi-as-objects/tests/tls_env_vars.rs b/seed/cli/query-parameters-openapi-as-objects/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/query-parameters-openapi-as-objects/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/query-parameters-openapi-as-objects/tests/websocket_wire.rs b/seed/cli/query-parameters-openapi-as-objects/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/query-parameters-openapi-as-objects/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/query-parameters-openapi-as-objects/tests/x_name_server_alias_wire.rs b/seed/cli/query-parameters-openapi-as-objects/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/query-parameters-openapi-as-objects/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/query-parameters-openapi/no-custom-config/.github/workflows/ci.yml b/seed/cli/query-parameters-openapi/no-custom-config/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/query-parameters-openapi/no-custom-config/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/query-parameters-openapi/no-custom-config/.github/workflows/release.yml b/seed/cli/query-parameters-openapi/no-custom-config/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/query-parameters-openapi/no-custom-config/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/query-parameters-openapi/no-custom-config/Cargo.lock b/seed/cli/query-parameters-openapi/no-custom-config/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/Cargo.lock +++ b/seed/cli/query-parameters-openapi/no-custom-config/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/query-parameters-openapi/no-custom-config/Cargo.toml b/seed/cli/query-parameters-openapi/no-custom-config/Cargo.toml index 3933b64faa24..5d3bcf79c003 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/Cargo.toml +++ b/seed/cli/query-parameters-openapi/no-custom-config/Cargo.toml @@ -1,10 +1,3 @@ -# `name`, `repository`, `homepage`, `authors`, and `keywords` are Fern's — -# they identify the SDK template's source on crates.io. The fern-cli -# generator does NOT rewrite this block when producing your CLI; only the -# [[bin]] entry below is templated. If you want to publish *your* CLI as -# its own crate on crates.io, edit this block to your org's metadata. -# The [lib] name (`fern_cli_sdk`) is the import path every `use -# fern_cli_sdk::...` site in src/ depends on — do NOT rename it. [package] name = "fern-cli-sdk" version = "0.18.1" @@ -13,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -22,19 +14,10 @@ categories = ["command-line-utilities", "web-programming"] name = "fern_cli_sdk" path = "src/lib.rs" -# Rewritten by the fern-cli generator's `patchCargoToml` step — both the -# `name` and `path` are replaced with the derived binary name so users -# get `cargo install`-able binaries named after their API rather than -# the template's literal "openapi-fixture". [[bin]] name = "query-parameters-api" path = "cli/query-parameters-api/main.rs" -# Internal tool used by the SDK template itself — not the user's CLI. -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" - [features] # TLS backend selection. # @@ -81,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/query-parameters-openapi/no-custom-config/cli/query-parameters-api/main.rs b/seed/cli/query-parameters-openapi/no-custom-config/cli/query-parameters-api/main.rs index a849a381fb1d..255a5e98adfe 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/cli/query-parameters-api/main.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/cli/query-parameters-api/main.rs @@ -1,10 +1,14 @@ // Auto-generated by @fern-api/cli-generator's copySpecs step. // Edit the SDK template / generator if you need to change the shape. -use fern_cli_sdk::openapi::CliApp; +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; fn main() { CliApp::new("query-parameters-api") - .spec(include_str!("openapi0.json")) + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) .run() } diff --git a/seed/cli/query-parameters-openapi/no-custom-config/dist-workspace.toml b/seed/cli/query-parameters-openapi/no-custom-config/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/dist-workspace.toml +++ b/seed/cli/query-parameters-openapi/no-custom-config/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/app.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/query-parameters-openapi/no-custom-config/src/arg_source.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/query-parameters-openapi/no-custom-config/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/seed/cli/query-parameters-openapi/no-custom-config/src/auth/builder.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/auth/builder.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/auth/mod.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/auth/mod.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/auth/root_builder.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/binding.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/query-parameters-openapi/no-custom-config/src/cli_args.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/cli_args.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/query-parameters-openapi/no-custom-config/src/completions.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/completions.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/custom_commands.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/custom_commands.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/early_intercept.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/early_intercept.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/error.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/error.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/query-parameters-openapi/no-custom-config/src/formatter.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/formatter.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/graphql/app.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/graphql/app.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/graphql/binding.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/graphql/commands.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/graphql/commands.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/graphql/mod.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/graphql/mod.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/query-parameters-openapi/no-custom-config/src/hooks.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/query-parameters-openapi/no-custom-config/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/seed/cli/query-parameters-openapi/no-custom-config/src/lib.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/lib.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/logging.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/logging.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/man.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/man.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/__fixtures__/openapi.json b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 9b465f33a3e9..000000000000 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Test Fixture API", - "version": "1.0.0" - }, - "paths": { - "/users": { - "get": { - "x-fern-sdk-group-name": ["users"], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "parameters": [ - { - "name": "limit", - "in": "query", - "schema": { "type": "integer" } - } - ], - "responses": { - "200": { "description": "OK" } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": ["files"], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { "description": "OK" } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": ["files"], - "x-fern-sdk-method-name": "thumbnail", - "operationId": "files_thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { "type": "string" } - } - ], - "responses": { - "200": { "description": "OK" } - } - } - } - } -} diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/app.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/app.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/binding.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/commands.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/commands.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/discovery.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/discovery.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/executor.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/executor.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/help.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/help.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/mod.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/mod.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/overlay.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/overlay.rs index d3b0f3cd72b0..85659b5da950 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/overlay.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1887,12 +1887,48 @@ actions: ); } - // (Previously: an integration smoke that exercised the rich - // template fixture's groups/methods after overlay. Coverage moved - // to `tests/cli_integration.rs` + `tests/openapi_fixture_wire.rs` - // — both of which exec the openapi-fixture bin against the rich - // fixture and assert deeper than this lib test ever could. The - // remaining `test_overlay_on_fixture_spec` above already covers - // the overlay→merge→build_doc lib path against the tiny shipped - // fixture.) + #[test] + fn test_overlay_on_fixture_spec_builds_cli_app() { + use crate::openapi::CliApp; + + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); + let overlay = r#" +overlay: "1.0.0" +info: + title: fixture-overlay + version: "1.0.0" +actions: + - target: "$.paths['/files/{file_id}/thumbnail']" + remove: true +"#; + + let app = CliApp::new("overlay-fixture") + .spec(spec) + .overlay(overlay); + let doc = app.build_doc().unwrap(); + + // files and folders groups should still exist + assert!(doc.resources.contains_key("files"), "files group missing"); + assert!(doc.resources.contains_key("folders"), "folders group missing"); + assert!(doc.resources.contains_key("users"), "users group missing"); + + // getThumbnail should be gone from the files resource + let files = &doc.resources["files"]; + assert!( + !files.methods.contains_key("getThumbnail"), + "getThumbnail should be removed: {:?}", + files.methods.keys().collect::>() + ); + // Other file operations should still exist + assert!( + files.methods.contains_key("get"), + "get should remain: {:?}", + files.methods.keys().collect::>() + ); + assert!( + files.methods.contains_key("update"), + "update should remain: {:?}", + files.methods.keys().collect::>() + ); + } } diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/parser.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/parser.rs +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/skill_emitter.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/query-parameters-openapi/no-custom-config/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/query-parameters-openapi/no-custom-config/src/stability.rs b/seed/cli/query-parameters-openapi/no-custom-config/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/query-parameters-openapi/no-custom-config/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/seed/cli/query-parameters-openapi/no-custom-config/tests/auth_routing_wire.rs b/seed/cli/query-parameters-openapi/no-custom-config/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/query-parameters-openapi/no-custom-config/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/query-parameters-openapi/no-custom-config/tests/common/mod.rs b/seed/cli/query-parameters-openapi/no-custom-config/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/query-parameters-openapi/no-custom-config/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/query-parameters-openapi/no-custom-config/tests/lib_api.rs b/seed/cli/query-parameters-openapi/no-custom-config/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/query-parameters-openapi/no-custom-config/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/query-parameters-openapi/no-custom-config/tests/openapi_streaming_wire.rs b/seed/cli/query-parameters-openapi/no-custom-config/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/query-parameters-openapi/no-custom-config/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/query-parameters-openapi/no-custom-config/tests/tls_env_vars.rs b/seed/cli/query-parameters-openapi/no-custom-config/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/query-parameters-openapi/no-custom-config/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/query-parameters-openapi/no-custom-config/tests/websocket_wire.rs b/seed/cli/query-parameters-openapi/no-custom-config/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/query-parameters-openapi/no-custom-config/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/query-parameters-openapi/no-custom-config/tests/x_name_server_alias_wire.rs b/seed/cli/query-parameters-openapi/no-custom-config/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/query-parameters-openapi/no-custom-config/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/schemaless-request-body-examples/.github/workflows/ci.yml b/seed/cli/schemaless-request-body-examples/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/schemaless-request-body-examples/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/schemaless-request-body-examples/.github/workflows/release.yml b/seed/cli/schemaless-request-body-examples/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/schemaless-request-body-examples/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/schemaless-request-body-examples/Cargo.lock b/seed/cli/schemaless-request-body-examples/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/schemaless-request-body-examples/Cargo.lock +++ b/seed/cli/schemaless-request-body-examples/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/schemaless-request-body-examples/Cargo.toml b/seed/cli/schemaless-request-body-examples/Cargo.toml index 3173f130532a..94ab1d5b8cf0 100644 --- a/seed/cli/schemaless-request-body-examples/Cargo.toml +++ b/seed/cli/schemaless-request-body-examples/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "schemaless-request-body-examples-api" +path = "cli/schemaless-request-body-examples-api/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/schemaless-request-body-examples/cli/openapi-fixture/main.rs b/seed/cli/schemaless-request-body-examples/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/schemaless-request-body-examples/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/schemaless-request-body-examples/cli/schemaless-request-body-examples-api/main.rs b/seed/cli/schemaless-request-body-examples/cli/schemaless-request-body-examples-api/main.rs new file mode 100644 index 000000000000..a4b711e73797 --- /dev/null +++ b/seed/cli/schemaless-request-body-examples/cli/schemaless-request-body-examples-api/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("schemaless-request-body-examples-api") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/schemaless-request-body-examples/cli/openapi-fixture/openapi0.json b/seed/cli/schemaless-request-body-examples/cli/schemaless-request-body-examples-api/openapi0.json similarity index 100% rename from seed/cli/schemaless-request-body-examples/cli/openapi-fixture/openapi0.json rename to seed/cli/schemaless-request-body-examples/cli/schemaless-request-body-examples-api/openapi0.json diff --git a/seed/cli/schemaless-request-body-examples/dist-workspace.toml b/seed/cli/schemaless-request-body-examples/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/schemaless-request-body-examples/dist-workspace.toml +++ b/seed/cli/schemaless-request-body-examples/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/schemaless-request-body-examples/src/app.rs b/seed/cli/schemaless-request-body-examples/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/schemaless-request-body-examples/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/schemaless-request-body-examples/src/arg_source.rs b/seed/cli/schemaless-request-body-examples/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/schemaless-request-body-examples/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/seed/cli/schemaless-request-body-examples/src/auth/builder.rs b/seed/cli/schemaless-request-body-examples/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/schemaless-request-body-examples/src/auth/builder.rs +++ b/seed/cli/schemaless-request-body-examples/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/schemaless-request-body-examples/src/auth/mod.rs b/seed/cli/schemaless-request-body-examples/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/schemaless-request-body-examples/src/auth/mod.rs +++ b/seed/cli/schemaless-request-body-examples/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/schemaless-request-body-examples/src/auth/root_builder.rs b/seed/cli/schemaless-request-body-examples/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/schemaless-request-body-examples/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/schemaless-request-body-examples/src/binding.rs b/seed/cli/schemaless-request-body-examples/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/schemaless-request-body-examples/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/schemaless-request-body-examples/src/cli_args.rs b/seed/cli/schemaless-request-body-examples/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/schemaless-request-body-examples/src/cli_args.rs +++ b/seed/cli/schemaless-request-body-examples/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/schemaless-request-body-examples/src/completions.rs b/seed/cli/schemaless-request-body-examples/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/schemaless-request-body-examples/src/completions.rs +++ b/seed/cli/schemaless-request-body-examples/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/schemaless-request-body-examples/src/custom_commands.rs b/seed/cli/schemaless-request-body-examples/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/schemaless-request-body-examples/src/custom_commands.rs +++ b/seed/cli/schemaless-request-body-examples/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/schemaless-request-body-examples/src/early_intercept.rs b/seed/cli/schemaless-request-body-examples/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/schemaless-request-body-examples/src/early_intercept.rs +++ b/seed/cli/schemaless-request-body-examples/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/schemaless-request-body-examples/src/error.rs b/seed/cli/schemaless-request-body-examples/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/schemaless-request-body-examples/src/error.rs +++ b/seed/cli/schemaless-request-body-examples/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/schemaless-request-body-examples/src/formatter.rs b/seed/cli/schemaless-request-body-examples/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/schemaless-request-body-examples/src/formatter.rs +++ b/seed/cli/schemaless-request-body-examples/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/schemaless-request-body-examples/src/graphql/app.rs b/seed/cli/schemaless-request-body-examples/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/schemaless-request-body-examples/src/graphql/app.rs +++ b/seed/cli/schemaless-request-body-examples/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/schemaless-request-body-examples/src/graphql/binding.rs b/seed/cli/schemaless-request-body-examples/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/schemaless-request-body-examples/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/schemaless-request-body-examples/src/graphql/commands.rs b/seed/cli/schemaless-request-body-examples/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/schemaless-request-body-examples/src/graphql/commands.rs +++ b/seed/cli/schemaless-request-body-examples/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/schemaless-request-body-examples/src/graphql/mod.rs b/seed/cli/schemaless-request-body-examples/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/schemaless-request-body-examples/src/graphql/mod.rs +++ b/seed/cli/schemaless-request-body-examples/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/schemaless-request-body-examples/src/hooks.rs b/seed/cli/schemaless-request-body-examples/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/schemaless-request-body-examples/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/seed/cli/schemaless-request-body-examples/src/lib.rs b/seed/cli/schemaless-request-body-examples/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/schemaless-request-body-examples/src/lib.rs +++ b/seed/cli/schemaless-request-body-examples/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/schemaless-request-body-examples/src/logging.rs b/seed/cli/schemaless-request-body-examples/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/schemaless-request-body-examples/src/logging.rs +++ b/seed/cli/schemaless-request-body-examples/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/schemaless-request-body-examples/src/man.rs b/seed/cli/schemaless-request-body-examples/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/schemaless-request-body-examples/src/man.rs +++ b/seed/cli/schemaless-request-body-examples/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/schemaless-request-body-examples/src/openapi/__fixtures__/openapi.json b/seed/cli/schemaless-request-body-examples/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/schemaless-request-body-examples/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/schemaless-request-body-examples/src/openapi/app.rs b/seed/cli/schemaless-request-body-examples/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/schemaless-request-body-examples/src/openapi/app.rs +++ b/seed/cli/schemaless-request-body-examples/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/schemaless-request-body-examples/src/openapi/binding.rs b/seed/cli/schemaless-request-body-examples/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/schemaless-request-body-examples/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/schemaless-request-body-examples/src/openapi/commands.rs b/seed/cli/schemaless-request-body-examples/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/schemaless-request-body-examples/src/openapi/commands.rs +++ b/seed/cli/schemaless-request-body-examples/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/schemaless-request-body-examples/src/openapi/discovery.rs b/seed/cli/schemaless-request-body-examples/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/schemaless-request-body-examples/src/openapi/discovery.rs +++ b/seed/cli/schemaless-request-body-examples/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/schemaless-request-body-examples/src/openapi/executor.rs b/seed/cli/schemaless-request-body-examples/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/schemaless-request-body-examples/src/openapi/executor.rs +++ b/seed/cli/schemaless-request-body-examples/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/schemaless-request-body-examples/src/openapi/help.rs b/seed/cli/schemaless-request-body-examples/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/schemaless-request-body-examples/src/openapi/help.rs +++ b/seed/cli/schemaless-request-body-examples/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/schemaless-request-body-examples/src/openapi/mod.rs b/seed/cli/schemaless-request-body-examples/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/schemaless-request-body-examples/src/openapi/mod.rs +++ b/seed/cli/schemaless-request-body-examples/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/schemaless-request-body-examples/src/openapi/overlay.rs b/seed/cli/schemaless-request-body-examples/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/schemaless-request-body-examples/src/openapi/overlay.rs +++ b/seed/cli/schemaless-request-body-examples/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/schemaless-request-body-examples/src/openapi/parser.rs b/seed/cli/schemaless-request-body-examples/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/schemaless-request-body-examples/src/openapi/parser.rs +++ b/seed/cli/schemaless-request-body-examples/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/schemaless-request-body-examples/src/openapi/skill_emitter.rs b/seed/cli/schemaless-request-body-examples/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/schemaless-request-body-examples/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/schemaless-request-body-examples/src/stability.rs b/seed/cli/schemaless-request-body-examples/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/schemaless-request-body-examples/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/seed/cli/schemaless-request-body-examples/tests/auth_routing_wire.rs b/seed/cli/schemaless-request-body-examples/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/schemaless-request-body-examples/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/schemaless-request-body-examples/tests/common/mod.rs b/seed/cli/schemaless-request-body-examples/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/schemaless-request-body-examples/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/schemaless-request-body-examples/tests/lib_api.rs b/seed/cli/schemaless-request-body-examples/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/schemaless-request-body-examples/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/schemaless-request-body-examples/tests/openapi_streaming_wire.rs b/seed/cli/schemaless-request-body-examples/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/schemaless-request-body-examples/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/schemaless-request-body-examples/tests/tls_env_vars.rs b/seed/cli/schemaless-request-body-examples/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/schemaless-request-body-examples/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/schemaless-request-body-examples/tests/websocket_wire.rs b/seed/cli/schemaless-request-body-examples/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/schemaless-request-body-examples/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/schemaless-request-body-examples/tests/x_name_server_alias_wire.rs b/seed/cli/schemaless-request-body-examples/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/schemaless-request-body-examples/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/server-sent-events-openapi/.github/workflows/ci.yml b/seed/cli/server-sent-events-openapi/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/server-sent-events-openapi/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/server-sent-events-openapi/.github/workflows/release.yml b/seed/cli/server-sent-events-openapi/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/server-sent-events-openapi/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/server-sent-events-openapi/Cargo.lock b/seed/cli/server-sent-events-openapi/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/server-sent-events-openapi/Cargo.lock +++ b/seed/cli/server-sent-events-openapi/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/server-sent-events-openapi/Cargo.toml b/seed/cli/server-sent-events-openapi/Cargo.toml index 3173f130532a..4eb67d78b555 100644 --- a/seed/cli/server-sent-events-openapi/Cargo.toml +++ b/seed/cli/server-sent-events-openapi/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "server-sent-events-openapi" +path = "cli/server-sent-events-openapi/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/server-sent-events-openapi/cli/openapi-fixture/main.rs b/seed/cli/server-sent-events-openapi/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/server-sent-events-openapi/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/server-sent-events-openapi/cli/server-sent-events-openapi/main.rs b/seed/cli/server-sent-events-openapi/cli/server-sent-events-openapi/main.rs new file mode 100644 index 000000000000..46d2b7da6229 --- /dev/null +++ b/seed/cli/server-sent-events-openapi/cli/server-sent-events-openapi/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("server-sent-events-openapi") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/server-sent-events-openapi/cli/openapi-fixture/openapi0.json b/seed/cli/server-sent-events-openapi/cli/server-sent-events-openapi/openapi0.json similarity index 100% rename from seed/cli/server-sent-events-openapi/cli/openapi-fixture/openapi0.json rename to seed/cli/server-sent-events-openapi/cli/server-sent-events-openapi/openapi0.json diff --git a/seed/cli/server-sent-events-openapi/dist-workspace.toml b/seed/cli/server-sent-events-openapi/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/server-sent-events-openapi/dist-workspace.toml +++ b/seed/cli/server-sent-events-openapi/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/server-sent-events-openapi/src/app.rs b/seed/cli/server-sent-events-openapi/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/server-sent-events-openapi/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/server-sent-events-openapi/src/arg_source.rs b/seed/cli/server-sent-events-openapi/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/server-sent-events-openapi/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/seed/cli/server-sent-events-openapi/src/auth/builder.rs b/seed/cli/server-sent-events-openapi/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/server-sent-events-openapi/src/auth/builder.rs +++ b/seed/cli/server-sent-events-openapi/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/server-sent-events-openapi/src/auth/mod.rs b/seed/cli/server-sent-events-openapi/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/server-sent-events-openapi/src/auth/mod.rs +++ b/seed/cli/server-sent-events-openapi/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/server-sent-events-openapi/src/auth/root_builder.rs b/seed/cli/server-sent-events-openapi/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/server-sent-events-openapi/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/server-sent-events-openapi/src/binding.rs b/seed/cli/server-sent-events-openapi/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/server-sent-events-openapi/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/server-sent-events-openapi/src/cli_args.rs b/seed/cli/server-sent-events-openapi/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/server-sent-events-openapi/src/cli_args.rs +++ b/seed/cli/server-sent-events-openapi/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/server-sent-events-openapi/src/completions.rs b/seed/cli/server-sent-events-openapi/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/server-sent-events-openapi/src/completions.rs +++ b/seed/cli/server-sent-events-openapi/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/server-sent-events-openapi/src/custom_commands.rs b/seed/cli/server-sent-events-openapi/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/server-sent-events-openapi/src/custom_commands.rs +++ b/seed/cli/server-sent-events-openapi/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/server-sent-events-openapi/src/early_intercept.rs b/seed/cli/server-sent-events-openapi/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/server-sent-events-openapi/src/early_intercept.rs +++ b/seed/cli/server-sent-events-openapi/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/server-sent-events-openapi/src/error.rs b/seed/cli/server-sent-events-openapi/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/server-sent-events-openapi/src/error.rs +++ b/seed/cli/server-sent-events-openapi/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/server-sent-events-openapi/src/formatter.rs b/seed/cli/server-sent-events-openapi/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/server-sent-events-openapi/src/formatter.rs +++ b/seed/cli/server-sent-events-openapi/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/server-sent-events-openapi/src/graphql/app.rs b/seed/cli/server-sent-events-openapi/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/server-sent-events-openapi/src/graphql/app.rs +++ b/seed/cli/server-sent-events-openapi/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/server-sent-events-openapi/src/graphql/binding.rs b/seed/cli/server-sent-events-openapi/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/server-sent-events-openapi/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/server-sent-events-openapi/src/graphql/commands.rs b/seed/cli/server-sent-events-openapi/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/server-sent-events-openapi/src/graphql/commands.rs +++ b/seed/cli/server-sent-events-openapi/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/server-sent-events-openapi/src/graphql/mod.rs b/seed/cli/server-sent-events-openapi/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/server-sent-events-openapi/src/graphql/mod.rs +++ b/seed/cli/server-sent-events-openapi/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/server-sent-events-openapi/src/hooks.rs b/seed/cli/server-sent-events-openapi/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/server-sent-events-openapi/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/seed/cli/server-sent-events-openapi/src/lib.rs b/seed/cli/server-sent-events-openapi/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/server-sent-events-openapi/src/lib.rs +++ b/seed/cli/server-sent-events-openapi/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/server-sent-events-openapi/src/logging.rs b/seed/cli/server-sent-events-openapi/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/server-sent-events-openapi/src/logging.rs +++ b/seed/cli/server-sent-events-openapi/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/server-sent-events-openapi/src/man.rs b/seed/cli/server-sent-events-openapi/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/server-sent-events-openapi/src/man.rs +++ b/seed/cli/server-sent-events-openapi/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/server-sent-events-openapi/src/openapi/__fixtures__/openapi.json b/seed/cli/server-sent-events-openapi/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/server-sent-events-openapi/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/server-sent-events-openapi/src/openapi/app.rs b/seed/cli/server-sent-events-openapi/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/server-sent-events-openapi/src/openapi/app.rs +++ b/seed/cli/server-sent-events-openapi/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/server-sent-events-openapi/src/openapi/binding.rs b/seed/cli/server-sent-events-openapi/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/server-sent-events-openapi/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/server-sent-events-openapi/src/openapi/commands.rs b/seed/cli/server-sent-events-openapi/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/server-sent-events-openapi/src/openapi/commands.rs +++ b/seed/cli/server-sent-events-openapi/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/server-sent-events-openapi/src/openapi/discovery.rs b/seed/cli/server-sent-events-openapi/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/server-sent-events-openapi/src/openapi/discovery.rs +++ b/seed/cli/server-sent-events-openapi/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/server-sent-events-openapi/src/openapi/executor.rs b/seed/cli/server-sent-events-openapi/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/server-sent-events-openapi/src/openapi/executor.rs +++ b/seed/cli/server-sent-events-openapi/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/server-sent-events-openapi/src/openapi/help.rs b/seed/cli/server-sent-events-openapi/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/server-sent-events-openapi/src/openapi/help.rs +++ b/seed/cli/server-sent-events-openapi/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/server-sent-events-openapi/src/openapi/mod.rs b/seed/cli/server-sent-events-openapi/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/server-sent-events-openapi/src/openapi/mod.rs +++ b/seed/cli/server-sent-events-openapi/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/server-sent-events-openapi/src/openapi/overlay.rs b/seed/cli/server-sent-events-openapi/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/server-sent-events-openapi/src/openapi/overlay.rs +++ b/seed/cli/server-sent-events-openapi/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/server-sent-events-openapi/src/openapi/parser.rs b/seed/cli/server-sent-events-openapi/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/server-sent-events-openapi/src/openapi/parser.rs +++ b/seed/cli/server-sent-events-openapi/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/server-sent-events-openapi/src/openapi/skill_emitter.rs b/seed/cli/server-sent-events-openapi/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/server-sent-events-openapi/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/server-sent-events-openapi/src/stability.rs b/seed/cli/server-sent-events-openapi/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/server-sent-events-openapi/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/seed/cli/server-sent-events-openapi/tests/auth_routing_wire.rs b/seed/cli/server-sent-events-openapi/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/server-sent-events-openapi/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/server-sent-events-openapi/tests/common/mod.rs b/seed/cli/server-sent-events-openapi/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/server-sent-events-openapi/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/server-sent-events-openapi/tests/lib_api.rs b/seed/cli/server-sent-events-openapi/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/server-sent-events-openapi/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/server-sent-events-openapi/tests/openapi_streaming_wire.rs b/seed/cli/server-sent-events-openapi/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/server-sent-events-openapi/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/server-sent-events-openapi/tests/tls_env_vars.rs b/seed/cli/server-sent-events-openapi/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/server-sent-events-openapi/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/server-sent-events-openapi/tests/websocket_wire.rs b/seed/cli/server-sent-events-openapi/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/server-sent-events-openapi/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/server-sent-events-openapi/tests/x_name_server_alias_wire.rs b/seed/cli/server-sent-events-openapi/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/server-sent-events-openapi/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/server-url-templating/.github/workflows/ci.yml b/seed/cli/server-url-templating/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/server-url-templating/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/server-url-templating/.github/workflows/release.yml b/seed/cli/server-url-templating/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/server-url-templating/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/server-url-templating/Cargo.lock b/seed/cli/server-url-templating/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/server-url-templating/Cargo.lock +++ b/seed/cli/server-url-templating/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/server-url-templating/Cargo.toml b/seed/cli/server-url-templating/Cargo.toml index 3173f130532a..249018f1b41c 100644 --- a/seed/cli/server-url-templating/Cargo.toml +++ b/seed/cli/server-url-templating/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "server-url-templating-api" +path = "cli/server-url-templating-api/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/server-url-templating/cli/openapi-fixture/main.rs b/seed/cli/server-url-templating/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/server-url-templating/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/server-url-templating/cli/server-url-templating-api/main.rs b/seed/cli/server-url-templating/cli/server-url-templating-api/main.rs new file mode 100644 index 000000000000..87f54f46a454 --- /dev/null +++ b/seed/cli/server-url-templating/cli/server-url-templating-api/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("server-url-templating-api") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/server-url-templating/cli/openapi-fixture/openapi0.json b/seed/cli/server-url-templating/cli/server-url-templating-api/openapi0.json similarity index 100% rename from seed/cli/server-url-templating/cli/openapi-fixture/openapi0.json rename to seed/cli/server-url-templating/cli/server-url-templating-api/openapi0.json diff --git a/seed/cli/server-url-templating/dist-workspace.toml b/seed/cli/server-url-templating/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/server-url-templating/dist-workspace.toml +++ b/seed/cli/server-url-templating/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/server-url-templating/src/app.rs b/seed/cli/server-url-templating/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/server-url-templating/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/server-url-templating/src/arg_source.rs b/seed/cli/server-url-templating/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/server-url-templating/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/seed/cli/server-url-templating/src/auth/builder.rs b/seed/cli/server-url-templating/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/server-url-templating/src/auth/builder.rs +++ b/seed/cli/server-url-templating/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/server-url-templating/src/auth/mod.rs b/seed/cli/server-url-templating/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/server-url-templating/src/auth/mod.rs +++ b/seed/cli/server-url-templating/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/server-url-templating/src/auth/root_builder.rs b/seed/cli/server-url-templating/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/server-url-templating/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/server-url-templating/src/binding.rs b/seed/cli/server-url-templating/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/server-url-templating/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/server-url-templating/src/cli_args.rs b/seed/cli/server-url-templating/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/server-url-templating/src/cli_args.rs +++ b/seed/cli/server-url-templating/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/server-url-templating/src/completions.rs b/seed/cli/server-url-templating/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/server-url-templating/src/completions.rs +++ b/seed/cli/server-url-templating/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/server-url-templating/src/custom_commands.rs b/seed/cli/server-url-templating/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/server-url-templating/src/custom_commands.rs +++ b/seed/cli/server-url-templating/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/server-url-templating/src/early_intercept.rs b/seed/cli/server-url-templating/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/server-url-templating/src/early_intercept.rs +++ b/seed/cli/server-url-templating/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/server-url-templating/src/error.rs b/seed/cli/server-url-templating/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/server-url-templating/src/error.rs +++ b/seed/cli/server-url-templating/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/server-url-templating/src/formatter.rs b/seed/cli/server-url-templating/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/server-url-templating/src/formatter.rs +++ b/seed/cli/server-url-templating/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/server-url-templating/src/graphql/app.rs b/seed/cli/server-url-templating/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/server-url-templating/src/graphql/app.rs +++ b/seed/cli/server-url-templating/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/server-url-templating/src/graphql/binding.rs b/seed/cli/server-url-templating/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/server-url-templating/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/server-url-templating/src/graphql/commands.rs b/seed/cli/server-url-templating/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/server-url-templating/src/graphql/commands.rs +++ b/seed/cli/server-url-templating/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/server-url-templating/src/graphql/mod.rs b/seed/cli/server-url-templating/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/server-url-templating/src/graphql/mod.rs +++ b/seed/cli/server-url-templating/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/server-url-templating/src/hooks.rs b/seed/cli/server-url-templating/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/server-url-templating/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/seed/cli/server-url-templating/src/lib.rs b/seed/cli/server-url-templating/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/server-url-templating/src/lib.rs +++ b/seed/cli/server-url-templating/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/server-url-templating/src/logging.rs b/seed/cli/server-url-templating/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/server-url-templating/src/logging.rs +++ b/seed/cli/server-url-templating/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/server-url-templating/src/man.rs b/seed/cli/server-url-templating/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/server-url-templating/src/man.rs +++ b/seed/cli/server-url-templating/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/server-url-templating/src/openapi/__fixtures__/openapi.json b/seed/cli/server-url-templating/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/server-url-templating/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/server-url-templating/src/openapi/app.rs b/seed/cli/server-url-templating/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/server-url-templating/src/openapi/app.rs +++ b/seed/cli/server-url-templating/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/server-url-templating/src/openapi/binding.rs b/seed/cli/server-url-templating/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/server-url-templating/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/server-url-templating/src/openapi/commands.rs b/seed/cli/server-url-templating/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/server-url-templating/src/openapi/commands.rs +++ b/seed/cli/server-url-templating/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/server-url-templating/src/openapi/discovery.rs b/seed/cli/server-url-templating/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/server-url-templating/src/openapi/discovery.rs +++ b/seed/cli/server-url-templating/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/server-url-templating/src/openapi/executor.rs b/seed/cli/server-url-templating/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/server-url-templating/src/openapi/executor.rs +++ b/seed/cli/server-url-templating/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/server-url-templating/src/openapi/help.rs b/seed/cli/server-url-templating/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/server-url-templating/src/openapi/help.rs +++ b/seed/cli/server-url-templating/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/server-url-templating/src/openapi/mod.rs b/seed/cli/server-url-templating/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/server-url-templating/src/openapi/mod.rs +++ b/seed/cli/server-url-templating/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/server-url-templating/src/openapi/overlay.rs b/seed/cli/server-url-templating/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/server-url-templating/src/openapi/overlay.rs +++ b/seed/cli/server-url-templating/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/server-url-templating/src/openapi/parser.rs b/seed/cli/server-url-templating/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/server-url-templating/src/openapi/parser.rs +++ b/seed/cli/server-url-templating/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/server-url-templating/src/openapi/skill_emitter.rs b/seed/cli/server-url-templating/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/server-url-templating/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/server-url-templating/src/stability.rs b/seed/cli/server-url-templating/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/server-url-templating/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/seed/cli/server-url-templating/tests/auth_routing_wire.rs b/seed/cli/server-url-templating/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/server-url-templating/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/server-url-templating/tests/common/mod.rs b/seed/cli/server-url-templating/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/server-url-templating/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/server-url-templating/tests/lib_api.rs b/seed/cli/server-url-templating/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/server-url-templating/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/server-url-templating/tests/openapi_streaming_wire.rs b/seed/cli/server-url-templating/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/server-url-templating/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/server-url-templating/tests/tls_env_vars.rs b/seed/cli/server-url-templating/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/server-url-templating/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/server-url-templating/tests/websocket_wire.rs b/seed/cli/server-url-templating/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/server-url-templating/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/server-url-templating/tests/x_name_server_alias_wire.rs b/seed/cli/server-url-templating/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/server-url-templating/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/url-form-encoded/.github/workflows/ci.yml b/seed/cli/url-form-encoded/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/url-form-encoded/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/url-form-encoded/.github/workflows/release.yml b/seed/cli/url-form-encoded/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/url-form-encoded/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/url-form-encoded/Cargo.lock b/seed/cli/url-form-encoded/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/url-form-encoded/Cargo.lock +++ b/seed/cli/url-form-encoded/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/url-form-encoded/Cargo.toml b/seed/cli/url-form-encoded/Cargo.toml index 3173f130532a..01fbd12afa41 100644 --- a/seed/cli/url-form-encoded/Cargo.toml +++ b/seed/cli/url-form-encoded/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "url-form-encoded-api" +path = "cli/url-form-encoded-api/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/url-form-encoded/cli/openapi-fixture/main.rs b/seed/cli/url-form-encoded/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/url-form-encoded/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/url-form-encoded/cli/url-form-encoded-api/main.rs b/seed/cli/url-form-encoded/cli/url-form-encoded-api/main.rs new file mode 100644 index 000000000000..663c3d27a23f --- /dev/null +++ b/seed/cli/url-form-encoded/cli/url-form-encoded-api/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("url-form-encoded-api") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/url-form-encoded/cli/openapi-fixture/openapi0.json b/seed/cli/url-form-encoded/cli/url-form-encoded-api/openapi0.json similarity index 100% rename from seed/cli/url-form-encoded/cli/openapi-fixture/openapi0.json rename to seed/cli/url-form-encoded/cli/url-form-encoded-api/openapi0.json diff --git a/seed/cli/url-form-encoded/dist-workspace.toml b/seed/cli/url-form-encoded/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/url-form-encoded/dist-workspace.toml +++ b/seed/cli/url-form-encoded/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/url-form-encoded/src/app.rs b/seed/cli/url-form-encoded/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/url-form-encoded/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/url-form-encoded/src/arg_source.rs b/seed/cli/url-form-encoded/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/url-form-encoded/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/seed/cli/url-form-encoded/src/auth/builder.rs b/seed/cli/url-form-encoded/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/url-form-encoded/src/auth/builder.rs +++ b/seed/cli/url-form-encoded/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/url-form-encoded/src/auth/mod.rs b/seed/cli/url-form-encoded/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/url-form-encoded/src/auth/mod.rs +++ b/seed/cli/url-form-encoded/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/url-form-encoded/src/auth/root_builder.rs b/seed/cli/url-form-encoded/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/url-form-encoded/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/url-form-encoded/src/binding.rs b/seed/cli/url-form-encoded/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/url-form-encoded/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/url-form-encoded/src/cli_args.rs b/seed/cli/url-form-encoded/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/url-form-encoded/src/cli_args.rs +++ b/seed/cli/url-form-encoded/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/url-form-encoded/src/completions.rs b/seed/cli/url-form-encoded/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/url-form-encoded/src/completions.rs +++ b/seed/cli/url-form-encoded/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/url-form-encoded/src/custom_commands.rs b/seed/cli/url-form-encoded/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/url-form-encoded/src/custom_commands.rs +++ b/seed/cli/url-form-encoded/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/url-form-encoded/src/early_intercept.rs b/seed/cli/url-form-encoded/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/url-form-encoded/src/early_intercept.rs +++ b/seed/cli/url-form-encoded/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/url-form-encoded/src/error.rs b/seed/cli/url-form-encoded/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/url-form-encoded/src/error.rs +++ b/seed/cli/url-form-encoded/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/url-form-encoded/src/formatter.rs b/seed/cli/url-form-encoded/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/url-form-encoded/src/formatter.rs +++ b/seed/cli/url-form-encoded/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/url-form-encoded/src/graphql/app.rs b/seed/cli/url-form-encoded/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/url-form-encoded/src/graphql/app.rs +++ b/seed/cli/url-form-encoded/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/url-form-encoded/src/graphql/binding.rs b/seed/cli/url-form-encoded/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/url-form-encoded/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/url-form-encoded/src/graphql/commands.rs b/seed/cli/url-form-encoded/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/url-form-encoded/src/graphql/commands.rs +++ b/seed/cli/url-form-encoded/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/url-form-encoded/src/graphql/mod.rs b/seed/cli/url-form-encoded/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/url-form-encoded/src/graphql/mod.rs +++ b/seed/cli/url-form-encoded/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/url-form-encoded/src/hooks.rs b/seed/cli/url-form-encoded/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/url-form-encoded/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/seed/cli/url-form-encoded/src/lib.rs b/seed/cli/url-form-encoded/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/url-form-encoded/src/lib.rs +++ b/seed/cli/url-form-encoded/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/url-form-encoded/src/logging.rs b/seed/cli/url-form-encoded/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/url-form-encoded/src/logging.rs +++ b/seed/cli/url-form-encoded/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/url-form-encoded/src/man.rs b/seed/cli/url-form-encoded/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/url-form-encoded/src/man.rs +++ b/seed/cli/url-form-encoded/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/url-form-encoded/src/openapi/__fixtures__/openapi.json b/seed/cli/url-form-encoded/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/url-form-encoded/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/url-form-encoded/src/openapi/app.rs b/seed/cli/url-form-encoded/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/url-form-encoded/src/openapi/app.rs +++ b/seed/cli/url-form-encoded/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/url-form-encoded/src/openapi/binding.rs b/seed/cli/url-form-encoded/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/url-form-encoded/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/url-form-encoded/src/openapi/commands.rs b/seed/cli/url-form-encoded/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/url-form-encoded/src/openapi/commands.rs +++ b/seed/cli/url-form-encoded/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/url-form-encoded/src/openapi/discovery.rs b/seed/cli/url-form-encoded/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/url-form-encoded/src/openapi/discovery.rs +++ b/seed/cli/url-form-encoded/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/url-form-encoded/src/openapi/executor.rs b/seed/cli/url-form-encoded/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/url-form-encoded/src/openapi/executor.rs +++ b/seed/cli/url-form-encoded/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/url-form-encoded/src/openapi/help.rs b/seed/cli/url-form-encoded/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/url-form-encoded/src/openapi/help.rs +++ b/seed/cli/url-form-encoded/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/url-form-encoded/src/openapi/mod.rs b/seed/cli/url-form-encoded/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/url-form-encoded/src/openapi/mod.rs +++ b/seed/cli/url-form-encoded/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/url-form-encoded/src/openapi/overlay.rs b/seed/cli/url-form-encoded/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/url-form-encoded/src/openapi/overlay.rs +++ b/seed/cli/url-form-encoded/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/url-form-encoded/src/openapi/parser.rs b/seed/cli/url-form-encoded/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/url-form-encoded/src/openapi/parser.rs +++ b/seed/cli/url-form-encoded/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/url-form-encoded/src/openapi/skill_emitter.rs b/seed/cli/url-form-encoded/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/url-form-encoded/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/url-form-encoded/src/stability.rs b/seed/cli/url-form-encoded/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/url-form-encoded/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/seed/cli/url-form-encoded/tests/auth_routing_wire.rs b/seed/cli/url-form-encoded/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/url-form-encoded/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/url-form-encoded/tests/common/mod.rs b/seed/cli/url-form-encoded/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/url-form-encoded/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/url-form-encoded/tests/lib_api.rs b/seed/cli/url-form-encoded/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/url-form-encoded/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/url-form-encoded/tests/openapi_streaming_wire.rs b/seed/cli/url-form-encoded/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/url-form-encoded/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/url-form-encoded/tests/tls_env_vars.rs b/seed/cli/url-form-encoded/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/url-form-encoded/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/url-form-encoded/tests/websocket_wire.rs b/seed/cli/url-form-encoded/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/url-form-encoded/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/url-form-encoded/tests/x_name_server_alias_wire.rs b/seed/cli/url-form-encoded/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/url-form-encoded/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/webhook-audience/.github/workflows/ci.yml b/seed/cli/webhook-audience/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/webhook-audience/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/webhook-audience/.github/workflows/release.yml b/seed/cli/webhook-audience/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/webhook-audience/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/webhook-audience/Cargo.lock b/seed/cli/webhook-audience/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/webhook-audience/Cargo.lock +++ b/seed/cli/webhook-audience/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/webhook-audience/Cargo.toml b/seed/cli/webhook-audience/Cargo.toml index 3173f130532a..a2d9a8619d3e 100644 --- a/seed/cli/webhook-audience/Cargo.toml +++ b/seed/cli/webhook-audience/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "webhook-audience-test" +path = "cli/webhook-audience-test/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/webhook-audience/cli/openapi-fixture/main.rs b/seed/cli/webhook-audience/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/webhook-audience/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/webhook-audience/cli/webhook-audience-test/main.rs b/seed/cli/webhook-audience/cli/webhook-audience-test/main.rs new file mode 100644 index 000000000000..cbe0b0959383 --- /dev/null +++ b/seed/cli/webhook-audience/cli/webhook-audience-test/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("webhook-audience-test") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/webhook-audience/cli/openapi-fixture/openapi0.json b/seed/cli/webhook-audience/cli/webhook-audience-test/openapi0.json similarity index 100% rename from seed/cli/webhook-audience/cli/openapi-fixture/openapi0.json rename to seed/cli/webhook-audience/cli/webhook-audience-test/openapi0.json diff --git a/seed/cli/webhook-audience/dist-workspace.toml b/seed/cli/webhook-audience/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/webhook-audience/dist-workspace.toml +++ b/seed/cli/webhook-audience/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/webhook-audience/src/app.rs b/seed/cli/webhook-audience/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/webhook-audience/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/webhook-audience/src/arg_source.rs b/seed/cli/webhook-audience/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/webhook-audience/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/seed/cli/webhook-audience/src/auth/builder.rs b/seed/cli/webhook-audience/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/webhook-audience/src/auth/builder.rs +++ b/seed/cli/webhook-audience/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/webhook-audience/src/auth/mod.rs b/seed/cli/webhook-audience/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/webhook-audience/src/auth/mod.rs +++ b/seed/cli/webhook-audience/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/webhook-audience/src/auth/root_builder.rs b/seed/cli/webhook-audience/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/webhook-audience/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/webhook-audience/src/binding.rs b/seed/cli/webhook-audience/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/webhook-audience/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/webhook-audience/src/cli_args.rs b/seed/cli/webhook-audience/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/webhook-audience/src/cli_args.rs +++ b/seed/cli/webhook-audience/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/webhook-audience/src/completions.rs b/seed/cli/webhook-audience/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/webhook-audience/src/completions.rs +++ b/seed/cli/webhook-audience/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/webhook-audience/src/custom_commands.rs b/seed/cli/webhook-audience/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/webhook-audience/src/custom_commands.rs +++ b/seed/cli/webhook-audience/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/webhook-audience/src/early_intercept.rs b/seed/cli/webhook-audience/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/webhook-audience/src/early_intercept.rs +++ b/seed/cli/webhook-audience/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/webhook-audience/src/error.rs b/seed/cli/webhook-audience/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/webhook-audience/src/error.rs +++ b/seed/cli/webhook-audience/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/webhook-audience/src/formatter.rs b/seed/cli/webhook-audience/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/webhook-audience/src/formatter.rs +++ b/seed/cli/webhook-audience/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/webhook-audience/src/graphql/app.rs b/seed/cli/webhook-audience/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/webhook-audience/src/graphql/app.rs +++ b/seed/cli/webhook-audience/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/webhook-audience/src/graphql/binding.rs b/seed/cli/webhook-audience/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/webhook-audience/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/webhook-audience/src/graphql/commands.rs b/seed/cli/webhook-audience/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/webhook-audience/src/graphql/commands.rs +++ b/seed/cli/webhook-audience/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/webhook-audience/src/graphql/mod.rs b/seed/cli/webhook-audience/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/webhook-audience/src/graphql/mod.rs +++ b/seed/cli/webhook-audience/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/webhook-audience/src/hooks.rs b/seed/cli/webhook-audience/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/webhook-audience/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/seed/cli/webhook-audience/src/lib.rs b/seed/cli/webhook-audience/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/webhook-audience/src/lib.rs +++ b/seed/cli/webhook-audience/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/webhook-audience/src/logging.rs b/seed/cli/webhook-audience/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/webhook-audience/src/logging.rs +++ b/seed/cli/webhook-audience/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/webhook-audience/src/man.rs b/seed/cli/webhook-audience/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/webhook-audience/src/man.rs +++ b/seed/cli/webhook-audience/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/webhook-audience/src/openapi/__fixtures__/openapi.json b/seed/cli/webhook-audience/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/webhook-audience/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/webhook-audience/src/openapi/app.rs b/seed/cli/webhook-audience/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/webhook-audience/src/openapi/app.rs +++ b/seed/cli/webhook-audience/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/webhook-audience/src/openapi/binding.rs b/seed/cli/webhook-audience/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/webhook-audience/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/webhook-audience/src/openapi/commands.rs b/seed/cli/webhook-audience/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/webhook-audience/src/openapi/commands.rs +++ b/seed/cli/webhook-audience/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/webhook-audience/src/openapi/discovery.rs b/seed/cli/webhook-audience/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/webhook-audience/src/openapi/discovery.rs +++ b/seed/cli/webhook-audience/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/webhook-audience/src/openapi/executor.rs b/seed/cli/webhook-audience/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/webhook-audience/src/openapi/executor.rs +++ b/seed/cli/webhook-audience/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/webhook-audience/src/openapi/help.rs b/seed/cli/webhook-audience/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/webhook-audience/src/openapi/help.rs +++ b/seed/cli/webhook-audience/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/webhook-audience/src/openapi/mod.rs b/seed/cli/webhook-audience/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/webhook-audience/src/openapi/mod.rs +++ b/seed/cli/webhook-audience/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/webhook-audience/src/openapi/overlay.rs b/seed/cli/webhook-audience/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/webhook-audience/src/openapi/overlay.rs +++ b/seed/cli/webhook-audience/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/webhook-audience/src/openapi/parser.rs b/seed/cli/webhook-audience/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/webhook-audience/src/openapi/parser.rs +++ b/seed/cli/webhook-audience/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/webhook-audience/src/openapi/skill_emitter.rs b/seed/cli/webhook-audience/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/webhook-audience/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/webhook-audience/src/stability.rs b/seed/cli/webhook-audience/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/webhook-audience/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/seed/cli/webhook-audience/tests/auth_routing_wire.rs b/seed/cli/webhook-audience/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/webhook-audience/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/webhook-audience/tests/common/mod.rs b/seed/cli/webhook-audience/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/webhook-audience/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/webhook-audience/tests/lib_api.rs b/seed/cli/webhook-audience/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/webhook-audience/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/webhook-audience/tests/openapi_streaming_wire.rs b/seed/cli/webhook-audience/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/webhook-audience/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/webhook-audience/tests/tls_env_vars.rs b/seed/cli/webhook-audience/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/webhook-audience/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/webhook-audience/tests/websocket_wire.rs b/seed/cli/webhook-audience/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/webhook-audience/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/webhook-audience/tests/x_name_server_alias_wire.rs b/seed/cli/webhook-audience/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/webhook-audience/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -} diff --git a/seed/cli/x-fern-default/.github/workflows/ci.yml b/seed/cli/x-fern-default/.github/workflows/ci.yml deleted file mode 100644 index 6a1880e58ee8..000000000000 --- a/seed/cli/x-fern-default/.github/workflows/ci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - run: cargo clippy -- -D warnings - - test: - name: Test - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: llvm-tools-preview - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - - name: Test with coverage - run: cargo llvm-cov --no-report --all-features --workspace - - - name: Coverage report - run: cargo llvm-cov report --summary-only --fail-under-lines 90 - - - name: Coverage HTML report - if: always() - run: cargo llvm-cov report --html - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-report - path: target/llvm-cov/html/ diff --git a/seed/cli/x-fern-default/.github/workflows/release.yml b/seed/cli/x-fern-default/.github/workflows/release.yml deleted file mode 100644 index 1339cd99a9a1..000000000000 --- a/seed/cli/x-fern-default/.github/workflows/release.yml +++ /dev/null @@ -1,323 +0,0 @@ -# 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@v4 - 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@v4 - 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 }} - # 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@v4 - 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@v4 - 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/* - - # Uncomment to publish to crates.io when ready - # publish-crates-io: - # needs: - # - plan - # - host - # runs-on: "ubuntu-22.04" - # if: ${{ always() && needs.host.result == 'success' }} - # env: - # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # steps: - # - uses: actions/checkout@v4 - # with: - # persist-credentials: false - # submodules: recursive - # - name: Install Rust - # run: rustup update stable --no-self-update && rustup default stable - # - name: Publish to crates.io - # run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} - - 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@v4 - with: - persist-credentials: false - submodules: recursive diff --git a/seed/cli/x-fern-default/Cargo.lock b/seed/cli/x-fern-default/Cargo.lock index 1283bd3b140c..a5a694a2abd8 100644 --- a/seed/cli/x-fern-default/Cargo.lock +++ b/seed/cli/x-fern-default/Cargo.lock @@ -353,6 +353,7 @@ dependencies = [ "clap_complete", "clap_mangen", "dotenvy", + "form_urlencoded", "futures-util", "hmac", "httpdate", @@ -1584,9 +1585,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", diff --git a/seed/cli/x-fern-default/Cargo.toml b/seed/cli/x-fern-default/Cargo.toml index 3173f130532a..d9a209bc11db 100644 --- a/seed/cli/x-fern-default/Cargo.toml +++ b/seed/cli/x-fern-default/Cargo.toml @@ -6,7 +6,6 @@ description = "CLI generator — dynamic command surface from OpenAPI and GraphQ license = "Apache-2.0" repository = "https://github.com/fern-api/cli-sdk" homepage = "https://github.com/fern-api/cli-sdk" -readme = "README.md" authors = ["Fern "] keywords = ["cli", "openapi", "graphql", "fern", "codegen"] categories = ["command-line-utilities", "web-programming"] @@ -16,12 +15,8 @@ name = "fern_cli_sdk" path = "src/lib.rs" [[bin]] -name = "openapi-fixture" -path = "cli/openapi-fixture/main.rs" - -[[bin]] -name = "strip-schema" -path = "src/bin/strip_schema.rs" +name = "x-fern-default-test" +path = "cli/x-fern-default-test/main.rs" [features] # TLS backend selection. @@ -69,15 +64,20 @@ tokio-util = { version = "0.7", features = ["io"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" +form_urlencoded = "1" [package.metadata.dist] -dist = false +dist = true # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + [dev-dependencies] serial_test = "3.4.0" tempfile = "3" diff --git a/seed/cli/x-fern-default/cli/openapi-fixture/main.rs b/seed/cli/x-fern-default/cli/openapi-fixture/main.rs deleted file mode 100644 index 94f41e8fb001..000000000000 --- a/seed/cli/x-fern-default/cli/openapi-fixture/main.rs +++ /dev/null @@ -1,11 +0,0 @@ -// Auto-generated by @fern-api/cli-generator's copySpecs step. -// Edit the SDK template / generator if you need to change the shape. - -use fern_cli_sdk::openapi::CliApp; - -fn main() { - CliApp::new("openapi-fixture") - .spec(include_str!("openapi0.json")) - .auth_scheme_env("bearer", "OPENAPI_FIXTURE_API_KEY") - .run() -} diff --git a/seed/cli/x-fern-default/cli/x-fern-default-test/main.rs b/seed/cli/x-fern-default/cli/x-fern-default-test/main.rs new file mode 100644 index 000000000000..95e5b2687128 --- /dev/null +++ b/seed/cli/x-fern-default/cli/x-fern-default-test/main.rs @@ -0,0 +1,14 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; + +fn main() { + CliApp::new("x-fern-default-test") + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ) + .run() +} diff --git a/seed/cli/x-fern-default/cli/openapi-fixture/openapi0.json b/seed/cli/x-fern-default/cli/x-fern-default-test/openapi0.json similarity index 100% rename from seed/cli/x-fern-default/cli/openapi-fixture/openapi0.json rename to seed/cli/x-fern-default/cli/x-fern-default-test/openapi0.json diff --git a/seed/cli/x-fern-default/dist-workspace.toml b/seed/cli/x-fern-default/dist-workspace.toml index d618b7018f95..db9541483dde 100644 --- a/seed/cli/x-fern-default/dist-workspace.toml +++ b/seed/cli/x-fern-default/dist-workspace.toml @@ -14,12 +14,8 @@ ci = "github" precise-builds = true # The installers to generate for each app installers = ["shell", "powershell", "npm"] -# A namespace to use when publishing this package to the npm registry -npm-scope = "@fern-api" # Whether to enable GitHub Attestations github-attestations = true -# The npm package should have this name -npm-package = "cli-sdk" # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "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 diff --git a/seed/cli/x-fern-default/src/app.rs b/seed/cli/x-fern-default/src/app.rs new file mode 100644 index 000000000000..c14009834a0a --- /dev/null +++ b/seed/cli/x-fern-default/src/app.rs @@ -0,0 +1,851 @@ +//! 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}; +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)>, +} + +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(), + } + } + + // ── 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 + } + + // ── 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 + } + + // ── 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 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 + } + + // ── 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(mut self) { + crate::reset_sigpipe(); + let _ = dotenvy::dotenv(); + crate::init_logging(&self.name); + + self.propagate_root_auth(); + + 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(std::env::args_os().collect(), &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(); + 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(); + 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) { + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// 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 { + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out); + 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 `--help --format json` before clap parses. + if crate::cli_args::wants_json_help(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + for binding in &self.bindings { + if binding.render_json_help(&path, out)? { + return Ok(PipelineOutcome::HelpShown); + } + } + } + + // 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 (default), table, yaml, csv") + .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), + ); + + // Collect each binding's subtree commands, global args, and help + // footer, then merge into the root. + let mut binding_commands: Vec<(usize, Vec)> = Vec::new(); + let mut after_help_sections: Vec = Vec::new(); + // Track registered arg IDs to avoid clap panic on duplicates + // when multiple bindings share the same global args (e.g. + // root-level CLI auth flags propagated to every binding). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + for (idx, binding) in self.bindings.iter().enumerate() { + let subcmd = binding.build_command()?; + // Record which top-level subcommand names belong to which binding. + for sub in subcmd.get_subcommands() { + binding_commands.push((idx, vec![sub.get_name().to_string()])); + } + // Merge this binding's subcommands into the root. + for sub in subcmd.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + // Merge binding-level global args (server vars, SDK vars, + // global headers) into the root command. + 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 binding's about into the root when CliApp + // doesn't override it. + if self.title.is_none() { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + // Collect after_help sections from all bindings for + // composition (concatenate, not overwrite). + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + // Deduplicate lines across bindings (preserving order) so + // two bindings sharing the same env vars or auth schemes + // don't repeat identical footer lines. + let merged = deduplicate_after_help(&after_help_sections); + cli = cli.after_help(merged); + } + + // 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` and `man` subcommands. + cli = cli + .subcommand(crate::completions::completion_command()) + .subcommand(crate::man::man_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); + + // 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_path( + &op_path, + &binding_commands, + ).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) + .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) => { + // 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) + .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) +} + +/// Find which binding index owns the first segment of the command path. +fn resolve_binding_for_path( + op_path: &[String], + binding_commands: &[(usize, Vec)], +) -> Option { + if op_path.is_empty() { + return None; + } + // Last-registered binding wins (matches design: "last binding wins"). + binding_commands + .iter() + .rev() + .find(|(_, cmd_path)| cmd_path.first() == op_path.first()) + .map(|(idx, _)| *idx) +} + +/// 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") +} + +// ── 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()]); + } + + #[test] + fn resolve_binding_last_wins() { + let commands = vec![ + (0, vec!["users".to_string()]), + (1, vec!["users".to_string()]), + ]; + let path = vec!["users".to_string(), "get".to_string()]; + assert_eq!(resolve_binding_for_path(&path, &commands), Some(1)); + } + + #[test] + fn resolve_binding_empty_path() { + let commands = vec![(0, vec!["users".to_string()])]; + assert_eq!(resolve_binding_for_path(&[], &commands), 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/seed/cli/x-fern-default/src/arg_source.rs b/seed/cli/x-fern-default/src/arg_source.rs new file mode 100644 index 000000000000..3111c9520bf1 --- /dev/null +++ b/seed/cli/x-fern-default/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/seed/cli/x-fern-default/src/auth/builder.rs b/seed/cli/x-fern-default/src/auth/builder.rs index beb30cae9960..e629dd01553d 100644 --- a/seed/cli/x-fern-default/src/auth/builder.rs +++ b/seed/cli/x-fern-default/src/auth/builder.rs @@ -857,4 +857,5 @@ mod tests { let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); assert_eq!(header(r, "x-custom").as_deref(), Some("c")); } + } diff --git a/seed/cli/x-fern-default/src/auth/mod.rs b/seed/cli/x-fern-default/src/auth/mod.rs index 89627b667385..6c7d7b703bb2 100644 --- a/seed/cli/x-fern-default/src/auth/mod.rs +++ b/seed/cli/x-fern-default/src/auth/mod.rs @@ -39,6 +39,7 @@ pub mod credential; pub mod error; pub mod oauth2; pub mod provider; +pub mod root_builder; pub mod schemes; #[cfg(test)] @@ -56,4 +57,5 @@ pub use provider::{ no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, }; pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/seed/cli/x-fern-default/src/auth/root_builder.rs b/seed/cli/x-fern-default/src/auth/root_builder.rs new file mode 100644 index 000000000000..8365b0db1f6f --- /dev/null +++ b/seed/cli/x-fern-default/src/auth/root_builder.rs @@ -0,0 +1,419 @@ +//! 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 super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; + +/// 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, +} + +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, + } + } + + /// 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 + } + + /// 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) { + // For OAuth2, the primary credential used for request auth is the + // access token (either static or obtained via client-credentials). + // The SchemeBinding::Token holds the access token source. The + // client_id/secret/refresh_token/token_url are consumed by the + // OAuth2TokenProvider at a higher level — this binding just declares + // "this scheme's credential is a bearer token sourced from X". + // + // If an access_token_env is set, use it directly (static token). + // Otherwise, fall through to Missing — the binding's build_auth_provider + // will detect the OAuth2 scheme type and construct an OAuth2TokenProvider + // using client_id, client_secret, and token_url. + let source = if matches!(self.access_token, AuthCredentialSource::Missing) { + // No static access token — token must be obtained via OAuth flow. + // Use a chain: access_token first (in case set at runtime), then Missing. + AuthCredentialSource::Missing + } else { + self.access_token + }; + (self.name, SchemeBinding::Token(source)) + } +} + +#[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")); + } + + #[test] + fn oauth2_auth_without_static_token_is_missing() { + 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"); + assert!(matches!(binding, SchemeBinding::Token(AuthCredentialSource::Missing))); + } + +} diff --git a/seed/cli/x-fern-default/src/binding.rs b/seed/cli/x-fern-default/src/binding.rs new file mode 100644 index 000000000000..2d051cc49679 --- /dev/null +++ b/seed/cli/x-fern-default/src/binding.rs @@ -0,0 +1,119 @@ +//! 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>; + + /// Render `--help --format json` for this binding. Returns `true` + /// if the binding handled the request (caller should exit 0), + /// `false` if the binding does not support JSON help. + fn render_json_help( + &self, + _subcommand_path: &[String], + _out: &mut dyn std::io::Write, + ) -> Result { + Ok(false) + } + + /// 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)]) {} + + /// 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/seed/cli/x-fern-default/src/cli_args.rs b/seed/cli/x-fern-default/src/cli_args.rs index 9ad689628024..54d5588496e2 100644 --- a/seed/cli/x-fern-default/src/cli_args.rs +++ b/seed/cli/x-fern-default/src/cli_args.rs @@ -3,6 +3,8 @@ //! 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. @@ -48,7 +50,7 @@ pub fn wants_json_help(args: &[String]) -> bool { /// Currently elided global flags: `--format ` (and its `--format=VALUE` /// equals form). /// -/// `["myapi", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` +/// `["box", "users", "get", "--help", "--format", "json"]` → `["users", "get"]` pub fn extract_subcommand_path(args: &[String]) -> Vec { let mut skip_next = false; args.iter() @@ -72,6 +74,117 @@ pub fn extract_subcommand_path(args: &[String]) -> Vec { .collect() } +/// 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") +} + +/// 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::*; @@ -91,37 +204,37 @@ mod tests { #[test] fn test_wants_json_help_space_separated() { assert!(wants_json_help(&args(&[ - "myapi", "issues", "--help", "--format", "json", + "linear", "issues", "--help", "--format", "json", ]))); } #[test] fn test_wants_json_help_equals() { - assert!(wants_json_help(&args(&["myapi", "--help", "--format=json"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=json"]))); } #[test] fn test_wants_json_help_short_flag() { - assert!(wants_json_help(&args(&["myapi", "-h", "--format", "json"]))); + assert!(wants_json_help(&args(&["linear", "-h", "--format", "json"]))); } #[test] fn test_wants_json_help_case_insensitive() { assert!(wants_json_help(&args(&[ - "myapi", "--help", "--format", "JSON", + "linear", "--help", "--format", "JSON", ]))); - assert!(wants_json_help(&args(&["myapi", "--help", "--format=JSON"]))); + assert!(wants_json_help(&args(&["linear", "--help", "--format=JSON"]))); } #[test] fn test_no_json_help_without_format() { - assert!(!wants_json_help(&args(&["myapi", "--help"]))); + assert!(!wants_json_help(&args(&["linear", "--help"]))); } #[test] fn test_no_json_help_without_help_flag() { assert!(!wants_json_help(&args(&[ - "myapi", "issues", "get", "--format", "json", + "linear", "issues", "get", "--format", "json", ]))); } @@ -129,7 +242,7 @@ mod tests { fn test_extract_subcommand_path() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "issues", "get", "--help", "--format", "json", + "linear", "issues", "get", "--help", "--format", "json", ])), vec!["issues", "get"], ); @@ -138,7 +251,7 @@ mod tests { #[test] fn test_extract_subcommand_path_root() { assert_eq!( - extract_subcommand_path(&args(&["myapi", "--help", "--format", "json"])), + extract_subcommand_path(&args(&["linear", "--help", "--format", "json"])), Vec::::new(), ); } @@ -147,7 +260,7 @@ mod tests { fn test_extract_subcommand_path_format_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format", "json", "issues", "--help", + "linear", "--format", "json", "issues", "--help", ])), vec!["issues"], ); @@ -157,9 +270,83 @@ mod tests { fn test_extract_subcommand_path_format_equals_before_subcommand() { assert_eq!( extract_subcommand_path(&args(&[ - "myapi", "--format=json", "issues", "get", "--help", + "linear", "--format=json", "issues", "get", "--help", ])), vec!["issues", "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/seed/cli/x-fern-default/src/completions.rs b/seed/cli/x-fern-default/src/completions.rs index 7d21ea16215f..84cdeb37686f 100644 --- a/seed/cli/x-fern-default/src/completions.rs +++ b/seed/cli/x-fern-default/src/completions.rs @@ -12,7 +12,7 @@ use clap_complete::{generate, Shell}; /// interception before normal API dispatch — avoiding collision with an /// API resource that might also be named `completion`. /// -/// Skips `--flag value` pairs so `myapi --base-url completion files` is +/// 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. @@ -20,18 +20,24 @@ 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 stdout. +/// 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. `"myapi"`). +/// `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 to stdout fails. -pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { +/// 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); - use std::io::Write; - std::io::stdout().write_all(&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. @@ -84,27 +90,27 @@ mod tests { #[test] fn wants_completion_detects_subcommand() { - assert!(wants_completion(&args(&["myapi", "completion", "bash"]))); - assert!(wants_completion(&args(&["myapi", "completion", "zsh"]))); + 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(&["myapi", "files", "get"]))); - assert!(!wants_completion(&args(&["myapi", "--help"]))); + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); } #[test] fn wants_completion_false_when_nested() { assert!(!wants_completion(&args(&[ - "myapi", "files", "completion", "bash" + "box", "files", "completion", "bash" ]))); } #[test] fn wants_completion_false_when_flag_value() { assert!(!wants_completion(&args(&[ - "myapi", + "box", "--base-url", "completion", "files", @@ -114,7 +120,7 @@ mod tests { #[test] fn wants_completion_true_after_eq_flag() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--base-url=http://localhost", "completion", "bash", @@ -126,7 +132,7 @@ mod tests { // --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(&[ - "myapi", + "box", "--dry-run", "completion", "bash", @@ -136,7 +142,7 @@ mod tests { #[test] fn wants_completion_with_multiple_boolean_flags() { assert!(wants_completion(&args(&[ - "myapi", + "box", "--dry-run", "--no-retry", "completion", diff --git a/seed/cli/x-fern-default/src/custom_commands.rs b/seed/cli/x-fern-default/src/custom_commands.rs index 6b487b4a2107..17b5e7e25fbd 100644 --- a/seed/cli/x-fern-default/src/custom_commands.rs +++ b/seed/cli/x-fern-default/src/custom_commands.rs @@ -1,101 +1,9 @@ -//! Protocol-agnostic registry for custom CLI subcommands grafted onto a -//! spec-derived command tree. +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. //! -//! Both the OpenAPI and GraphQL `CliApp` builders let consumers register -//! handlers for subcommands that live alongside spec-generated commands -//! (e.g. a `webhooks verify` leaf next to spec-generated `webhooks list`). -//! The grafting and dispatch logic is identical across protocols — only -//! the per-handler context type differs — so it lives here, generic over -//! the context type `C`. - -use crate::error::CliError; - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and the -/// per-protocol context `C` (typically the protocol's `AppContext`). -pub type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; - -/// A registered custom command: parent path, leaf [`clap::Command`], and -/// its handler. -type Entry = (Vec, clap::Command, HandlerFn); - -/// Registry of custom subcommands keyed by their parent path in the -/// spec-derived command tree. Empty path = top-level. -pub struct CustomCommandRegistry { - entries: Vec>, -} - -impl CustomCommandRegistry { - pub fn new() -> Self { - Self { entries: Vec::new() } - } - - /// Register a top-level custom subcommand. - pub fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { - self.register_under::<&str>(&[], cmd, handler); - } - - /// Register a custom subcommand under `path`. Empty path = top-level. - pub 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)); - } - - /// Graft every registered command into `cli`, returning the augmented - /// command tree. Custom commands replace spec-generated leaves on - /// name collisions. - pub fn graft_into(&self, mut cli: clap::Command) -> clap::Command { - for (path, cmd, _) in &self.entries { - cli = graft_subcommand(cli, path, cmd.clone()); - } - cli - } - - /// Walk the parsed `matches` tree along each registered command's - /// path. If one matches, invoke its handler with `ctx` and return - /// `Some(handler_result)`. Returns `None` if no custom command was - /// invoked. - pub 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 - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Crate-internal accessor used by unit tests in the protocol modules - /// to verify registration shape. - #[cfg(test)] - #[doc(hidden)] - pub(crate) fn entries(&self) -> &[Entry] { - &self.entries - } -} - -impl Default for CustomCommandRegistry { - fn default() -> Self { - Self::new() - } -} +//! 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()`. @@ -160,6 +68,68 @@ pub fn walk_matches_to_custom<'a>( #[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; diff --git a/seed/cli/x-fern-default/src/early_intercept.rs b/seed/cli/x-fern-default/src/early_intercept.rs index 41f02e7f2790..28a0d329319a 100644 --- a/seed/cli/x-fern-default/src/early_intercept.rs +++ b/seed/cli/x-fern-default/src/early_intercept.rs @@ -19,7 +19,7 @@ pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ /// Returns `true` when `args` contains `target` as the first positional /// token (i.e. the subcommand position). Skips `--flag value` pairs so -/// `myapi --base-url files` is not mistaken for the subcommand. +/// `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 { @@ -96,19 +96,19 @@ mod tests { #[test] fn first_positional_basic() { - assert!(first_positional_is(&args(&["myapi", "completion", "bash"]), "completion")); - assert!(first_positional_is(&args(&["myapi", "man"]), "man")); + 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(&["myapi", "files", "get"]), "completion")); + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); } #[test] fn first_positional_false_when_flag_value() { assert!(!first_positional_is( - &args(&["myapi", "--base-url", "man", "files"]), + &args(&["box", "--base-url", "man", "files"]), "man", )); } @@ -116,7 +116,7 @@ mod tests { #[test] fn first_positional_true_after_eq_flag() { assert!(first_positional_is( - &args(&["myapi", "--base-url=http://localhost", "man"]), + &args(&["box", "--base-url=http://localhost", "man"]), "man", )); } @@ -124,7 +124,7 @@ mod tests { #[test] fn first_positional_true_after_boolean_flag() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "completion", "bash"]), + &args(&["box", "--dry-run", "completion", "bash"]), "completion", )); } @@ -132,7 +132,7 @@ mod tests { #[test] fn first_positional_true_after_multiple_boolean_flags() { assert!(first_positional_is( - &args(&["myapi", "--dry-run", "--no-retry", "man"]), + &args(&["box", "--dry-run", "--no-retry", "man"]), "man", )); } @@ -144,7 +144,7 @@ mod tests { // `--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(&["myapi", "--base-url", "X", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), Some("bash"), ); } @@ -154,7 +154,7 @@ mod tests { // `--dry-run` is boolean, so "completion" is positional #0 and // "bash" is positional #1. assert_eq!( - nth_positional(&args(&["myapi", "--dry-run", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), Some("bash"), ); } @@ -162,7 +162,7 @@ mod tests { #[test] fn nth_positional_out_of_range() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 5), + nth_positional(&args(&["box", "completion", "bash"]), 5), None, ); } @@ -170,7 +170,7 @@ mod tests { #[test] fn nth_positional_zeroth() { assert_eq!( - nth_positional(&args(&["myapi", "completion", "bash"]), 0), + nth_positional(&args(&["box", "completion", "bash"]), 0), Some("completion"), ); } @@ -178,7 +178,7 @@ mod tests { #[test] fn nth_positional_eq_flag() { assert_eq!( - nth_positional(&args(&["myapi", "--base-url=http://localhost", "completion", "bash"]), 1), + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), Some("bash"), ); } diff --git a/seed/cli/x-fern-default/src/error.rs b/seed/cli/x-fern-default/src/error.rs index 7a8af32284be..e2d010a9e1d4 100644 --- a/seed/cli/x-fern-default/src/error.rs +++ b/seed/cli/x-fern-default/src/error.rs @@ -35,6 +35,24 @@ impl CliError { 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:#}")), + } + } + pub fn exit_code(&self) -> i32 { match self { CliError::Api { .. } => Self::EXIT_CODE_API, @@ -92,6 +110,111 @@ impl CliError { 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"), @@ -103,8 +226,13 @@ fn error_label(err: &CliError) -> String { } pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout()); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write) { let json = err.to_json(); - println!( + let _ = writeln!( + out, "{}", serde_json::to_string_pretty(&json).unwrap_or_default() ); @@ -203,4 +331,137 @@ mod tests { print_error_json(&CliError::Discovery("no spec".to_string())); print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); } + + #[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 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/seed/cli/x-fern-default/src/formatter.rs b/seed/cli/x-fern-default/src/formatter.rs index 91c56fefe0fd..24a6a39d0eaf 100644 --- a/seed/cli/x-fern-default/src/formatter.rs +++ b/seed/cli/x-fern-default/src/formatter.rs @@ -41,38 +41,38 @@ pub enum FormatError { pub struct OutputPipeline { pub format: OutputFormat, pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, } impl OutputPipeline { /// Build a pipeline from parsed CLI matches. /// - /// Unknown `--format` values emit a warning on stderr and fall back to - /// JSON, matching the prior behavior at `src/openapi/app.rs`. + /// 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) -> Result { let format = match matches.get_one::("format") { - Some(s) => match OutputFormat::parse(s) { - Ok(fmt) => fmt, - Err(unknown) => { - eprintln!( - "warning: unknown output format '{unknown}'; falling back to json" - ); - OutputFormat::Json - } - }, + Some(s) => OutputFormat::parse(s) + .map_err(FormatError::UnknownFormat)?, None => OutputFormat::default(), }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); Ok(Self { format, color_mode: ColorMode::Auto, + quiet, }) } /// Render `value` to `out`, appending a trailing newline. /// - /// When `paginated` is true the compact NDJSON form is used (one JSON - /// object per line); otherwise the pretty form is used. `is_first_page` - /// controls per-format first-page concerns (CSV headers, YAML separators, - /// table headers — see `format_value_paginated`). + /// When `quiet` is set, this is a no-op — the value is silently discarded. pub fn emit( &self, out: &mut W, @@ -80,6 +80,9 @@ impl OutputPipeline { 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 { @@ -881,10 +884,13 @@ mod tests { } #[test] - fn pipeline_from_matches_falls_back_to_json_on_unknown_format() { + fn pipeline_from_matches_rejects_unknown_format() { let matches = matches_for(&["test", "--format", "garbage"]); - let pipeline = OutputPipeline::from_matches(&matches).unwrap(); - assert_eq!(pipeline.format, OutputFormat::Json); + let err = OutputPipeline::from_matches(&matches).unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); } #[test] @@ -892,6 +898,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -908,6 +915,7 @@ mod tests { let pipeline = OutputPipeline { format: OutputFormat::Json, color_mode: ColorMode::Never, + quiet: false, }; let val = json!({"name": "test", "n": 1}); let mut buf: Vec = Vec::new(); @@ -920,4 +928,17 @@ mod tests { 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, + }; + 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"); + } } diff --git a/seed/cli/x-fern-default/src/graphql/app.rs b/seed/cli/x-fern-default/src/graphql/app.rs index 4f71e3ebf2e4..b04c4a6cf262 100644 --- a/seed/cli/x-fern-default/src/graphql/app.rs +++ b/seed/cli/x-fern-default/src/graphql/app.rs @@ -6,21 +6,11 @@ //! API programmatically. use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::graphql::commands; use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; use crate::graphql::executor; -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Builder for a schema-driven CLI application (GraphQL). pub struct CliApp { pub(crate) name: String, @@ -31,20 +21,20 @@ pub struct CliApp { /// constructed provider is `Any` by default — generators can flip /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that /// require multiple schemes simultaneously. - auth_bindings: Vec<(String, SchemeBinding)>, + 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. - extra_root_certs: Vec, + 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. - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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 { @@ -56,7 +46,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), } } @@ -147,39 +136,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands. - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the GraphQL schema), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -211,243 +167,29 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// Build the full CLI command tree including spec-derived subcommands, - /// custom commands, `completion`, `man`, and auth-bound global flags. - /// - /// Called from the `wants_completion` / `wants_man` early-intercept - /// blocks AND the normal-dispatch path so all three see the same tree. - fn build_full_cli( - &self, - doc: &crate::graphql::discovery::GraphQLSchema, - ) -> clap::Command { - let mut cli = self - .custom_commands - .graft_into(commands::build_cli(doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - // Register CLI-arg-bound credential sources as global flags. - for arg_name in crate::auth::collect_binding_cli_args(&self.auth_bindings) { - cli = cli.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - cli - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); - - // Load the GraphQL schema - let json = self.spec_json.as_deref().ok_or_else(|| { - CliError::Discovery("No spec provided. Call .spec() on CliApp.".to_string()) - })?; - let endpoint = self.endpoint_url.as_deref().ok_or_else(|| { - CliError::Discovery("No endpoint provided. Call .endpoint() on CliApp.".to_string()) - })?; - let doc = crate::graphql::load_graphql_schema(json, &self.name, endpoint)?; - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::graphql::help::render_json_help(&doc, &path); - } - - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = self.build_full_cli(&doc); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - let mut full_cmd = self.build_full_cli(&doc); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + /// Decorate a clap `Command` with the auth help section. + /// 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); } - } - - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let mut full_cmd = self.build_full_cli(&doc); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); - } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - - // Build the full command tree (same tree the intercept blocks use) - // for normal dispatch. `completion` and `man` subcommands are - // included so they appear in `--help`. - let cli = self.build_full_cli(&doc); - - // Parse args (clap handles --help automatically via arg_required_else_help) - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); + if let Some(ref s) = auth_section { + sections.push(s); } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); + cli = cli.after_help(sections.join("\n\n")); } - - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; - } - } - - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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 = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config - let pagination = build_pagination_config(matched_args); - - let auth_provider = self.build_auth_provider(); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - ) - .await - .map(|_| ()) + cli } + /// Construct the [`DynAuthProvider`] used for this run from the /// registered bindings. GraphQL has no spec-declared schemes; with no /// bindings, returns a `NoAuthProvider`. - fn build_auth_provider(&self) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { crate::auth::build_provider_with_strategy( &self.auth_bindings, &std::collections::HashMap::new(), @@ -455,21 +197,76 @@ impl CliApp { 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 and the constructed auth -/// provider. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, + entries: Vec, + /// Whether `--quiet` was passed on the command line. + quiet: 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, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -477,32 +274,62 @@ impl AppContext { 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, }; tokio::runtime::Handle::current() .block_on(executor::execute_method( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, false, &pagination, &pipeline, false, None, - &self.http_config, + &entry.http_config, )) .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.doc + &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. @@ -510,10 +337,29 @@ impl AppContext { /// 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.http_config + &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, @@ -565,7 +411,7 @@ pub fn resolve_method_from_matches<'a>( /// Collect individual flag values into a params map. /// Values from --params JSON override individual flags. -fn collect_params_from_flags( +pub(crate) fn collect_params_from_flags( matched_args: &clap::ArgMatches, method: &crate::graphql::discovery::GraphQLOperation, params_override: Option<&str>, @@ -592,7 +438,7 @@ fn collect_params_from_flags( Ok(params) } -fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { +pub(crate) fn build_pagination_config(matches: &clap::ArgMatches) -> executor::PaginationConfig { executor::PaginationConfig { page_all: matches.get_flag("page-all"), page_limit: matches @@ -633,32 +479,4 @@ mod tests { assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); } - #[test] - fn test_graphql_cli_app_custom_command_top_level() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command(clap::Command::new("custom"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_graphql_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { - Ok(()) - } - let app = CliApp::new("test") - .spec("{}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!( - app.custom_commands.entries()[0].0, - vec!["webhooks".to_string()] - ); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } } diff --git a/seed/cli/x-fern-default/src/graphql/binding.rs b/seed/cli/x-fern-default/src/graphql/binding.rs new file mode 100644 index 000000000000..2b732510efa4 --- /dev/null +++ b/seed/cli/x-fern-default/src/graphql/binding.rs @@ -0,0 +1,355 @@ +//! [`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::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, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + 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 + } + + 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_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + 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 doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + 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 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 render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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(); + + let dry_run = matched_args.get_flag("dry-run"); + let pagination = super::app::build_pagination_config(matched_args); + + 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 result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output + base_url_override, + &prepared.http_config, + ) + .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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/x-fern-default/src/graphql/commands.rs b/seed/cli/x-fern-default/src/graphql/commands.rs index 308ceca696e0..a65076c45209 100644 --- a/seed/cli/x-fern-default/src/graphql/commands.rs +++ b/seed/cli/x-fern-default/src/graphql/commands.rs @@ -18,6 +18,7 @@ const BUILTIN_FLAG_NAMES: &[&str] = &[ "page-all", "page-limit", "page-delay", + "quiet", "help", ]; @@ -52,6 +53,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -102,8 +111,8 @@ fn build_resource_command(name: &str, resource: &RestResource) -> Option Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub fn write_json_help( + doc: &GraphQLSchema, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &GraphQLSchema, path: &[String]) -> Result<(), CliE } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/x-fern-default/src/graphql/mod.rs b/seed/cli/x-fern-default/src/graphql/mod.rs index 765c987a5443..cd021beda24e 100644 --- a/seed/cli/x-fern-default/src/graphql/mod.rs +++ b/seed/cli/x-fern-default/src/graphql/mod.rs @@ -1,9 +1,12 @@ mod app; +mod binding; pub mod commands; mod help; pub mod executor; mod parser; pub mod discovery; -pub use self::app::{AppContext, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/x-fern-default/src/hooks.rs b/seed/cli/x-fern-default/src/hooks.rs new file mode 100644 index 000000000000..0e3398257463 --- /dev/null +++ b/seed/cli/x-fern-default/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/seed/cli/x-fern-default/src/lib.rs b/seed/cli/x-fern-default/src/lib.rs index 595a915f1876..304537e57f71 100644 --- a/seed/cli/x-fern-default/src/lib.rs +++ b/seed/cli/x-fern-default/src/lib.rs @@ -5,21 +5,26 @@ //! to build the command hierarchy. // Public API — building blocks +pub mod app; +pub mod arg_source; pub mod auth; +pub mod binding; pub mod cli_args; pub mod completions; -pub mod custom_commands; +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 stability; pub mod validate; pub mod websocket; -// Convenience re-exports for OAuth2 types -pub use auth::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; // Internal modules pub(crate) mod early_intercept; diff --git a/seed/cli/x-fern-default/src/logging.rs b/seed/cli/x-fern-default/src/logging.rs index b9a951a433aa..d90f70af5d4d 100644 --- a/seed/cli/x-fern-default/src/logging.rs +++ b/seed/cli/x-fern-default/src/logging.rs @@ -81,7 +81,7 @@ mod tests { #[test] fn test_env_prefix() { assert_eq!(env_prefix("test-cli"), "TEST_CLI"); - assert_eq!(env_prefix("myapi"), "MYAPI"); + assert_eq!(env_prefix("box"), "BOX"); assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); } diff --git a/seed/cli/x-fern-default/src/man.rs b/seed/cli/x-fern-default/src/man.rs index 5a1d0638ad3d..9bd15fd580c9 100644 --- a/seed/cli/x-fern-default/src/man.rs +++ b/seed/cli/x-fern-default/src/man.rs @@ -19,7 +19,7 @@ pub fn wants_man(args: &[String]) -> bool { /// 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. `"myapi"`). +/// `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. /// @@ -65,24 +65,24 @@ mod tests { #[test] fn wants_man_basic() { - assert!(wants_man(&args(&["myapi", "man"]))); + assert!(wants_man(&args(&["box", "man"]))); } #[test] fn wants_man_false_when_flag_value() { - assert!(!wants_man(&args(&["myapi", "--base-url", "man"]))); + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); } #[test] fn wants_man_with_boolean_flag() { - assert!(wants_man(&args(&["myapi", "--dry-run", "man"]))); + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); } #[test] fn generate_man_produces_roff() { - let cmd = Command::new("myapi").about("test"); + let cmd = Command::new("box").about("test"); let mut buf = Vec::new(); - generate_man_to(cmd, "myapi", &mut buf).expect("generate_man_to should succeed"); + 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"), @@ -90,7 +90,7 @@ mod tests { &output[..output.len().min(200)] ); assert!( - output.contains("myapi"), + output.contains("box"), "man page should contain the binary name" ); assert!( diff --git a/seed/cli/x-fern-default/src/openapi/__fixtures__/openapi.json b/seed/cli/x-fern-default/src/openapi/__fixtures__/openapi.json deleted file mode 100644 index 0dc13405c428..000000000000 --- a/seed/cli/x-fern-default/src/openapi/__fixtures__/openapi.json +++ /dev/null @@ -1,1302 +0,0 @@ -{ - "openapi": "3.0.2", - "info": { - "title": "Fixture API", - "version": "1.0", - "description": "Minimal targeted spec for integration testing. Not a real API." - }, - "servers": [ - { - "url": "https://api.fixture.example/v1" - } - ], - "x-fern-idempotency-headers": [ - { - "header": "Idempotency-Key", - "name": "idempotency_key" - }, - { - "header": "X-Trace-Id", - "name": "trace_id" - } - ], - "x-fern-sdk-variables": { - "gardenId": { - "type": "string", - "description": "The garden tenant identifier used to scope all zone operations." - } - }, - "x-fern-global-headers": [ - { - "header": "X-API-Stage", - "name": "apiStage", - "optional": false, - "env": "FIXTURE_API_STAGE", - "default": "production" - }, - { - "header": "X-Tenant-Id", - "name": "tenantId", - "optional": true - } - ], - "x-fern-groups": { - "users": { - "summary": "Users Operations", - "description": "Manage users — list, fetch, and mutate account records." - }, - "files": { - "summary": "Files Operations" - } - }, - "paths": { - "/users/me": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "getCurrent", - "operationId": "users_getCurrent", - "summary": "Get current user", - "responses": { - "200": { - "description": "Current user object" - } - } - } - }, - "/users": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "list", - "operationId": "users_list", - "summary": "List users", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "parameters": [ - { - "name": "filter_term", - "in": "query", - "x-fern-parameter-name": "searchQuery", - "description": "Free-text user filter. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - }, - { - "name": "user_type", - "in": "query", - "description": "Filter users by membership type.", - "x-fern-default": "all", - "schema": { - "type": "string", - "enum": [ - "all", - "managed", - "external" - ], - "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." - } - } - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer", - "default": 25 - } - }, - { - "name": "X-Fern-Version", - "in": "header", - "x-fern-parameter-name": "apiVersion", - "description": "API version pin. Renamed via x-fern-parameter-name.", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated user list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "create", - "operationId": "users_create", - "summary": "Create a user", - "x-fern-retries": { - "max_attempts": 3, - "base_delay_ms": 1, - "factor": 1, - "jitter": 0 - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Created user" - } - } - } - }, - "/users/{user_id}": { - "get": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "get", - "operationId": "users_get", - "summary": "Get a user by ID", - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "legacy_flag", - "in": "query", - "description": "Old flag retained server-side but hidden from the CLI surface.", - "x-fern-ignore": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User object" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "users" - ], - "x-fern-sdk-method-name": "hardDelete", - "operationId": "users_hardDelete", - "summary": "(Hidden) Hard-delete a user.", - "x-fern-ignore": true, - "parameters": [ - { - "name": "user_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/upload": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "upload", - "operationId": "files_upload", - "summary": "Upload a binary file", - "description": "Exercises the binary-body code path. The CLI exposes a `--file` flag\nfor ``, `@`, and `-` (stdin). Used by the wire test that\nverifies disk paths emit `Content-Length` and stdin emits\n`Transfer-Encoding: chunked`.\n", - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "responses": { - "200": { - "description": "Upload accepted" - } - } - } - }, - "/files/{file_id}": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "get", - "operationId": "files_get", - "summary": "Get a file by ID", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "File object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "update", - "operationId": "files_update", - "summary": "Update a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated file" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "files_delete", - "summary": "Delete a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/files/{file_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "files_copy", - "summary": "Copy a file", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Copied file" - } - } - } - }, - "/files/{file_id}/thumbnail": { - "get": { - "x-fern-sdk-group-name": [ - "files" - ], - "x-fern-sdk-method-name": "getThumbnail", - "operationId": "files_getThumbnail", - "summary": "Get a file thumbnail", - "parameters": [ - { - "name": "file_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Thumbnail image" - } - } - } - }, - "/folders": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "folders_create", - "summary": "Create a folder", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "parent": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created folder" - } - } - } - }, - "/folders/{folder_id}": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "get", - "operationId": "folders_get", - "summary": "Get a folder by ID", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder object" - } - } - }, - "put": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "update", - "operationId": "folders_update", - "summary": "Update a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Updated folder" - } - } - }, - "delete": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "delete", - "operationId": "folders_delete", - "summary": "Delete a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Deleted" - } - } - } - }, - "/folders/{folder_id}/items": { - "get": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "listItems", - "operationId": "folders_listItems", - "summary": "List items in a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Folder item list" - } - } - } - }, - "/folders/{folder_id}/copy": { - "post": { - "x-fern-sdk-group-name": [ - "folders" - ], - "x-fern-sdk-method-name": "copy", - "operationId": "folders_copy", - "summary": "Copy a folder", - "parameters": [ - { - "name": "folder_id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "201": { - "description": "Copied folder" - } - } - } - }, - "/events": { - "get": { - "x-fern-sdk-group-name": [ - "events" - ], - "x-fern-sdk-method-name": "list", - "operationId": "events_list", - "summary": "List paginated events", - "x-fern-pagination": { - "cursor": "$request.next_marker", - "next_cursor": "$response.next_marker", - "results": "$response.entries" - }, - "parameters": [ - { - "name": "next_marker", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Paginated event list" - } - } - } - }, - "/audit": { - "get": { - "x-fern-sdk-group-name": [ - "audit" - ], - "x-fern-sdk-method-name": "list", - "operationId": "audit_list", - "summary": "List audit entries (offset-paginated)", - "x-fern-pagination": { - "offset": "$request.offset", - "results": "$response.entries", - "step": "$request.limit" - }, - "parameters": [ - { - "name": "offset", - "in": "query", - "schema": { - "type": "integer" - } - }, - { - "name": "limit", - "in": "query", - "schema": { - "type": "integer" - } - } - ], - "responses": { - "200": { - "description": "Paginated audit list" - } - } - } - }, - "/payments": { - "get": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "list", - "operationId": "payments_list", - "summary": "List payments (non-idempotent)", - "responses": { - "200": { - "description": "Paginated payment list" - } - } - }, - "post": { - "x-fern-sdk-group-name": [ - "payments" - ], - "x-fern-sdk-method-name": "create", - "operationId": "payments_create", - "summary": "Create a payment (idempotent)", - "x-fern-idempotent": true, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "amount": { - "type": "integer" - }, - "currency": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created payment" - } - } - } - }, - "/experiments/beta": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "beta-op", - "x-fern-availability": "beta", - "operationId": "experiments_beta", - "summary": "Beta operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/pre-release": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "pre-release-op", - "x-fern-availability": "pre-release", - "operationId": "experiments_preRelease", - "summary": "Pre-release operation", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/ga": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "ga-op", - "x-fern-availability": "ga", - "operationId": "experiments_ga", - "summary": "Generally-available operation (alias) — should NOT carry a badge", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "deprecated-op", - "x-fern-availability": "deprecated", - "operationId": "experiments_deprecated", - "summary": "Deprecated operation — still callable", - "parameters": [ - { - "name": "legacy_flag", - "in": "query", - "description": "A flag that itself is marked beta to verify per-parameter badges.", - "x-fern-availability": "beta", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/experiments/openapi-deprecated": { - "get": { - "x-fern-sdk-group-name": [ - "experiments" - ], - "x-fern-sdk-method-name": "openapi-deprecated-op", - "deprecated": true, - "operationId": "experiments_openapiDeprecated", - "summary": "Op marked deprecated with OpenAPI's standard flag (no extension)", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/search": { - "get": { - "x-fern-sdk-group-name": [ - "search" - ], - "x-fern-sdk-method-name": "query", - "operationId": "search_query", - "summary": "Search with deep object filter", - "parameters": [ - { - "name": "filter", - "in": "query", - "style": "deepObject", - "explode": true, - "schema": { - "type": "object" - } - } - ], - "responses": { - "200": { - "description": "Search results" - } - } - } - }, - "/reports": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "list", - "operationId": "reports_list", - "summary": "List reports (envelope-wrapped)", - "x-fern-sdk-return-value": "data", - "responses": { - "200": { - "description": "Envelope with data + meta", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data", - "meta" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "page": { - "type": "integer" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/stats": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "getStats", - "operationId": "reports_getStats", - "summary": "Read a nested return value", - "x-fern-sdk-return-value": "result.payload", - "responses": { - "200": { - "description": "Two-level wrapper response", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "result" - ], - "properties": { - "result": { - "type": "object", - "properties": { - "payload": { - "type": "object", - "properties": { - "value": { - "type": "integer" - }, - "unit": { - "type": "string" - } - } - } - } - }, - "meta": { - "type": "object", - "properties": { - "server_time": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - }, - "/reports/paged": { - "get": { - "x-fern-sdk-group-name": [ - "reports" - ], - "x-fern-sdk-method-name": "listPaged", - "operationId": "reports_listPaged", - "summary": "Cursor-paginated reports with envelope extraction", - "x-fern-sdk-return-value": "data", - "x-fern-pagination": { - "cursor": "$request.cursor", - "next_cursor": "$response.next", - "results": "$response.data" - }, - "parameters": [ - { - "name": "cursor", - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Page of reports plus an envelope-level cursor", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name" - ], - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - } - }, - "next": { - "type": "string" - } - } - } - } - } - } - } - } - }, - "/gardens/{gardenId}/zones": { - "get": { - "x-fern-sdk-group-name": [ - "zones" - ], - "x-fern-sdk-method-name": "list", - "operationId": "zones_list", - "summary": "List zones in a garden (variable-bound path param).", - "parameters": [ - { - "name": "gardenId", - "in": "path", - "required": true, - "x-fern-sdk-variable": "gardenId", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/public-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "public-only", - "operationId": "audiences_public_only", - "summary": "Op tagged with x-fern-audiences=[public].", - "x-fern-audiences": [ - "public" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/internal-only": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "internal-only", - "operationId": "audiences_internal_only", - "summary": "Op tagged with x-fern-audiences=[internal].", - "x-fern-audiences": [ - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/untagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "untagged", - "operationId": "audiences_untagged", - "summary": "Op with no x-fern-audiences extension.", - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/audiences/multi-tagged": { - "get": { - "x-fern-sdk-group-name": [ - "audiences" - ], - "x-fern-sdk-method-name": "multi-tagged", - "operationId": "audiences_multi_tagged", - "summary": "Op tagged with x-fern-audiences=[public, internal].", - "x-fern-audiences": [ - "public", - "internal" - ], - "responses": { - "200": { - "description": "ok" - } - } - } - }, - "/things": { - "post": { - "x-fern-sdk-group-name": [ - "things" - ], - "x-fern-sdk-method-name": "create", - "operationId": "things_create", - "summary": "Create a thing", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string" - }, - "count": { - "type": "integer" - }, - "is_active": { - "type": "boolean" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "metadata": { - "type": "object" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created thing" - } - } - } - }, - "/persons": { - "post": { - "x-fern-sdk-group-name": [ - "persons" - ], - "x-fern-sdk-method-name": "create", - "operationId": "persons_create", - "summary": "Create a person (nested body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "object", - "properties": { - "first": { - "type": "string" - }, - "last": { - "type": "string" - } - } - }, - "role": { - "type": "string" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created person" - } - } - } - }, - "/articles": { - "post": { - "x-fern-sdk-group-name": [ - "articles" - ], - "x-fern-sdk-method-name": "create", - "operationId": "articles_create", - "summary": "Create an article (array body field)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "tag": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created article" - } - } - } - }, - "/widgets": { - "post": { - "x-fern-sdk-group-name": [ - "widgets" - ], - "x-fern-sdk-method-name": "create", - "operationId": "widgets_create", - "summary": "Create a widget ($ref body)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NewWidget" - } - } - } - }, - "responses": { - "201": { - "description": "Created widget" - } - } - } - }, - "/orders": { - "post": { - "x-fern-sdk-group-name": [ - "orders" - ], - "x-fern-sdk-method-name": "create", - "operationId": "orders_create", - "summary": "Create an order ($ref property within inline schema)", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "note": { - "type": "string" - }, - "address": { - "$ref": "#/components/schemas/Address" - } - } - } - } - } - }, - "responses": { - "201": { - "description": "Created order" - } - } - } - } - }, - "components": { - "schemas": { - "NewWidget": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "priority": { - "type": "integer" - } - } - }, - "Address": { - "type": "object", - "properties": { - "city": { - "type": "string" - }, - "zip": { - "type": "string" - } - } - } - } - } -} diff --git a/seed/cli/x-fern-default/src/openapi/app.rs b/seed/cli/x-fern-default/src/openapi/app.rs index eeb9ef27c379..e0dcfb9e7deb 100644 --- a/seed/cli/x-fern-default/src/openapi/app.rs +++ b/seed/cli/x-fern-default/src/openapi/app.rs @@ -8,11 +8,8 @@ use std::collections::HashMap; use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; -use crate::cli_args; -use crate::custom_commands::CustomCommandRegistry; -use crate::error::{print_error_json, CliError}; +use crate::error::CliError; use crate::formatter; -use crate::openapi::commands; use crate::openapi::discovery::{JsonSchema, RestDescription, RestMethod, RestResource}; use crate::openapi::executor; @@ -207,9 +204,9 @@ fn merge_schemas( acc: &mut HashMap, incoming: HashMap, ) -> Result<(), CliError> { - // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`, `Meta`) across many specs authored from the same - // template — collisions are the norm, not a bug. + // Multi-spec setups 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 @@ -467,175 +464,6 @@ pub(crate) fn compose_root_after_help_sections( sections.join("\n") } -/// Result of [`register_global_flags_with_help`] — carries both the -/// augmented command and the optional `Global headers:` help section -/// so callers can compose the root after-help footer. -struct RegisterGlobalFlagsResult { - cmd: clap::Command, - global_headers_section: Option, -} - -/// Register all global flags (server variables, SDK variables, global -/// headers, auth CLI args) onto `cmd`. Returns the augmented command. -/// Used by the completion path where the help-section text is not needed. -fn register_global_flags( - cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> clap::Command { - register_global_flags_with_help(cmd, server_vars, doc, auth_bindings).cmd -} - -/// Register all global flags and return both the command and the -/// optional `Global headers:` section for the root help footer. The -/// normal path uses this variant to compose the after-help text. -fn register_global_flags_with_help( - mut cmd: clap::Command, - server_vars: &[ServerVar], - doc: &RestDescription, - auth_bindings: &[(String, crate::auth::SchemeBinding)], -) -> RegisterGlobalFlagsResult { - for var in server_vars { - let kebab = var.name.replace('_', "-"); - 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()); - } - cmd = cmd.arg(arg); - } - - for var in &doc.sdk_variables { - let kebab = crate::text::to_kebab_flag(&var.name); - if sdk_variable_collides_with_builtin(&kebab) { - tracing::warn!( - "x-fern-sdk-variables entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename the \ - variable in the spec to avoid the collision.", - var.name, - kebab, - ); - continue; - } - let env_name = crate::text::to_screaming_snake(&var.name); - let help_text = var.description.clone().unwrap_or_else(|| { - format!( - "Value for the SDK variable '{}' (substituted into path templates)", - var.name - ) - }); - let arg = clap::Arg::new(var.name.clone()) - .long(kebab) - .global(true) - .value_name(env_name.clone()) - .help(help_text) - .env(env_name); - cmd = cmd.arg(arg); - } - - 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!( - "x-fern-global-headers entry '{}' would register --{} which \ - collides with a built-in flag; skipping. Rename via \ - `name:` in the spec to avoid the collision.", - h.header, - kebab, - ); - continue; - } - if !registered_kebabs.insert(kebab.clone()) { - tracing::warn!( - "x-fern-global-headers entry '{}' would register --{} which \ - duplicates an earlier global-header flag; skipping.", - h.header, - kebab, - ); - continue; - } - 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(global_header_arg_id(h)) - .long(kebab) - .global(true) - .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()); - } - cmd = cmd.arg(arg); - } - 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"))) - }; - - for arg_name in crate::auth::collect_binding_cli_args(auth_bindings) { - cmd = cmd.arg( - clap::Arg::new(arg_name.clone()) - .long(arg_name.clone()) - .global(true) - .value_name(arg_name.to_uppercase().replace('-', "_")) - .help(format!("Credential value for auth source `{arg_name}`")), - ); - } - - RegisterGlobalFlagsResult { cmd, global_headers_section } -} - -/// A custom command handler function. -/// -/// Receives the parsed [`clap::ArgMatches`] for the subcommand and an -/// [`AppContext`] that provides access to the spec, auth token, and -/// executor. -pub type HandlerFn = crate::custom_commands::HandlerFn; - /// Internal entry describing one OpenAPI spec to be merged. pub(crate) struct SpecEntry { yaml: String, @@ -663,8 +491,8 @@ pub(crate) struct ServerVar { name: String, /// Env var consulted when the flag isn't passed (e.g. `MYAPI_STORE_HASH`). env_var: Option, - /// Fallback default (for variables that have one — tenant/store - /// identifiers typically don't). + /// Fallback default (for variables that have one — most + /// store identifiers don't). default: Option, /// One-line `--help` string. description: Option, @@ -681,7 +509,7 @@ pub struct CliApp { /// [`auth_provider`](Self::auth_provider). The constructed provider is /// built from these (lowered against the spec's /// `components.securitySchemes`). - auth_bindings: Vec<(String, SchemeBinding)>, + 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. @@ -689,14 +517,13 @@ pub struct CliApp { /// 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. - extra_root_certs: Vec, + 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`). - extra_root_certs_pem: Vec>, - pub(crate) custom_commands: CustomCommandRegistry, + 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`). @@ -714,9 +541,10 @@ pub struct CliApp { /// 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`). - audiences: Vec, + pub(crate) audiences: 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 { @@ -729,7 +557,6 @@ impl CliApp { auth_strategy: AuthStrategy::Auto, extra_root_certs: Vec::new(), extra_root_certs_pem: Vec::new(), - custom_commands: CustomCommandRegistry::new(), server_vars: Vec::new(), idempotency_header_envs: HashMap::new(), audiences: Vec::new(), @@ -755,7 +582,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("my-public-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .audiences(["public"]) /// .run(); /// ``` @@ -784,7 +611,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") /// .run(); /// ``` @@ -808,8 +635,8 @@ impl CliApp { /// 3. The built-in default (if any) /// 4. Otherwise, errors with a helpful message /// - /// Used for multi-tenant APIs where every URL is parameterized - /// (e.g. `https://api.example.com/stores/{store_hash}/v3`). Variables + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is a `{store_hash}` placeholder. 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. @@ -988,7 +815,7 @@ impl CliApp { /// use fern_cli_sdk::openapi::CliApp; /// /// CliApp::new("my-api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .overlay(include_str!("overlay.yaml")) /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") /// .run() @@ -1093,7 +920,7 @@ impl CliApp { /// /// ```ignore /// CliApp::new("api") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .auth_scheme_env("bearerAuth", "API_TOKEN") /// .run(); /// ``` @@ -1205,40 +1032,6 @@ impl CliApp { self } - /// Register a custom top-level subcommand with its handler function. - /// - /// Equivalent to [`command_under`](Self::command_under) with an empty path. - pub fn command(mut self, cmd: clap::Command, handler: HandlerFn) -> Self { - self.custom_commands.register(cmd, handler); - self - } - - /// Register a custom subcommand under an existing path in the spec-derived - /// command tree. Useful for adding a new leaf alongside spec-generated - /// commands (e.g. grafting `webhooks verify` next to a spec-generated - /// `webhooks list` and `webhooks create`). - /// - /// - `path` — the parent path the command should be grafted under. An - /// empty path registers the command at the top level. Intermediate - /// parents that do not yet exist are auto-created. - /// - `cmd` — the leaf [`clap::Command`]. Its name becomes the final - /// segment of the path. - /// - `handler` — invoked with the [`clap::ArgMatches`] for the leaf and - /// the [`AppContext`]. - /// - /// If a subcommand with the same leaf name already exists at the target - /// path (e.g. from the OpenAPI spec), it is **replaced** by `cmd` — - /// custom commands take precedence on leaf collisions. - pub fn command_under>( - mut self, - path: &[S], - cmd: clap::Command, - handler: HandlerFn, - ) -> Self { - self.custom_commands.register_under(path, cmd, handler); - 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!`. @@ -1250,7 +1043,7 @@ impl CliApp { /// ```ignore /// # // ignored: needs a real PEM file at the include path. /// CliApp::new("internal-tool") - /// .spec(include_str!("openapi.json")) + /// .spec(include_str!("openapi.yaml")) /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) /// .run() /// ``` @@ -1269,371 +1062,208 @@ impl CliApp { self } - /// Run the CLI application. This is the main entry point. - /// - /// Builds a tokio runtime internally so the caller's `main()` does not - /// need to be async. - pub fn run(self) { - // Reset SIGPIPE to default so piped output (e.g. `| head`) doesn't - // panic. Must happen before any I/O. - crate::reset_sigpipe(); - - // Load .env file if present (silently ignored if missing) - let _ = dotenvy::dotenv(); - - // Initialize structured logging (no-op if env vars are unset) - crate::init_logging(&self.name); - - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - if let Err(err) = rt.block_on(self.run_async()) { - print_error_json(&err); - std::process::exit(err.exit_code()); - } - } - - /// The async implementation of the CLI run loop. - async fn run_async(mut self) -> Result<(), CliError> { - let args: Vec = std::env::args().collect(); - - // Handle --version early (before loading spec) - if args.iter().any(|a| cli_args::is_version_flag(a)) { - println!("{} {}", self.name, env!("CARGO_PKG_VERSION")); - return Ok(()); - } - - // Build the HTTP config once per run. Holds the binary name (used to - // scope env-var lookups) and any compile-time trust roots. The roots - // were already validated at builder time; we just thread the parsed - // certs through. - let http_config = crate::http::HttpConfig::new(&self.name)? - .with_parsed_root_certs( - self.extra_root_certs.iter().cloned(), - self.extra_root_certs_pem.iter().cloned(), - ); + /// 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 = crate::auth::render_auth_help_section(&self.auth_bindings); - // Load and merge all API specs - let mut doc = self.build_doc()?; - - // Apply the audience filter *before* anything else inspects - // `doc`. The filter physically removes operations whose - // `x-fern-audiences` doesn't intersect the binary's preset - // audience set, so excluded operations never appear in: - // - the JSON help output below (`render_json_help`), - // - the clap command tree (`build_cli`), - // - `--help` for any subcommand, - // - completions / introspection. - // - // Mirrors fern-api/fern's "drop from IR" semantics - // (`openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). - // The audience list is configured by the binary's `main.rs` via - // [`Self::audiences`] — a compile-time preset, not a runtime - // flag. An empty preset is a no-op (every operation included). - commands::filter_doc_by_audiences(&mut doc, &self.audiences); - - // Intercept --help --format json before clap parses, to emit machine-readable output - if cli_args::wants_json_help(&args) { - let path = cli_args::extract_subcommand_path(&args); - return crate::openapi::help::render_json_help(&doc, &path); + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = var.name.replace('_', "-"); + 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); } - // Intercept ` completion ` early — before normal API - // dispatch — so a spec resource named "completion" doesn't collide. - // Builds the full command tree (including global flags) so the - // generated script covers the entire CLI surface. - if crate::completions::wants_completion(&args) { - // Extract the shell name: positional #1 (since `completion` - // is positional #0), applying the same BOOLEAN_FLAGS-aware - // skip logic so `--base-url ` doesn't leak as the shell. - let raw_shell_arg: Option<&str> = - crate::early_intercept::nth_positional(&args, 1); - - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - match raw_shell_arg { - Some(s) => match crate::completions::parse_shell(s) { - Some(shell) => { - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - crate::completions::generate_completion( - shell, - &mut full_cmd, - &self.name, - ) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); - } - None => { - return Err(CliError::Validation(format!( - "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" - ))); - } - }, - None => { - // No shell argument — print friendly help and exit 0. - let mut full_cmd = register_global_flags( - base, - &self.server_vars, - &doc, - &self.auth_bindings, - ); - if let Some(sub) = full_cmd.find_subcommand_mut("completion") { - sub.print_help().ok(); - } - return Ok(()); - } + // 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); } - // Intercept ` man` early — same pattern as completion above. - // If `--help` / `-h` appears after `man`, fall through to normal - // clap dispatch so the subcommand help (with EXAMPLES) is shown - // instead of generating the man page. - if crate::man::wants_man(&args) { - let has_help = args.iter().skip(1).skip_while(|a| a.as_str() != "man").skip(1) - .any(|a| a == "--help" || a == "-h"); - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - let mut full_cmd = - register_global_flags(base, &self.server_vars, &doc, &self.auth_bindings); - if has_help { - if let Some(sub) = full_cmd.find_subcommand_mut("man") { - sub.print_help().ok(); - } - return Ok(()); + // 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) + .global(true) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); } - crate::man::generate_man(full_cmd, &self.name) - .map_err(|e| CliError::Other(e.into()))?; - return Ok(()); + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + cli = cli.arg(arg); } - // Build the dynamic command tree, then graft custom commands into - // it. Empty path → top-level. On leaf-name collision with a - // spec-generated command, custom wins. The `completion` and `man` - // subcommands are also registered here so they appear in `--help`. - let base = self - .custom_commands - .graft_into(commands::build_cli(&doc)) - .subcommand(crate::completions::completion_command()) - .subcommand(crate::man::man_command()); - - let RegisterGlobalFlagsResult { cmd: mut cli, global_headers_section } = - register_global_flags_with_help(base, &self.server_vars, &doc, &self.auth_bindings); - - let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → 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 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(), auth_section.as_deref(), - &commands::after_help_footer(&doc.name), + &base_footer, )); - // Parse args. clap raises a special `DisplayHelp*` "error" both for - // explicit `--help` and for the implicit help from - // `arg_required_else_help` — neither is a real failure, so print to - // stdout and exit 0 instead of wrapping in a validation error JSON. - let matches = cli.try_get_matches_from(&args).map_err(|e| { - if e.kind() == clap::error::ErrorKind::DisplayHelp - || e.kind() == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand - || e.kind() == clap::error::ErrorKind::DisplayVersion - { - print!("{e}"); - std::process::exit(0); - } - CliError::Validation(e.to_string()) - })?; - - // Finalize auth bindings against the parsed matches. After this, - // any `AuthCredentialSource::Cli(name)` in the bindings is replaced - // with a closure reading from the matches — so `build_auth_provider` - // (called below for both custom-command dispatch and regular - // execution) sees a fully resolvable provider. - if !self.auth_bindings.is_empty() { - let matches_arc = std::sync::Arc::new(matches.clone()); - self.auth_bindings = crate::auth::finalize_bindings( - std::mem::take(&mut self.auth_bindings), - &matches_arc, - ); - } + cli + } - // Substitute server variables in root_urls. Clap pulls from --flag - // first, then the registered env var (via .env()), then the default, - // so a single get_one lookup covers the full priority chain. - if !self.server_vars.is_empty() { - let mut substitutions: std::collections::HashMap = - std::collections::HashMap::new(); - for var in &self.server_vars { - if let Some(value) = matches.get_one::(&var.name) { - substitutions.insert(var.name.clone(), value.clone()); - } + /// 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(&mut doc, &substitutions); } + apply_server_var_substitutions(doc, &subs); + } - // Dispatch to a custom command if one was invoked. - if !self.custom_commands.is_empty() { - let auth_provider = self.build_auth_provider(&doc); - // Resolve global headers once for custom-command handlers. - // Required-header validation is deferred until execute/invoke - // is called, because the per-op override check needs to know - // the operation. Here we only collect CLI/env/default values. - let resolved_global_headers: Vec<(String, String)> = doc - .global_headers - .iter() - .filter_map(|h| resolve_global_header_value(&matches, h).map(|v| (h.header.clone(), v))) - .collect(); - let ctx = AppContext { - doc: doc.clone(), - auth_provider, - http_config: http_config.clone(), - global_headers: resolved_global_headers, - }; - if let Some(result) = self.custom_commands.dispatch(&matches, &ctx) { - return result; + /// 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() + )) + })?; } - // Build the output pipeline (format + color + later: --fields/--jq/--template). - let pipeline = formatter::OutputPipeline::from_matches(&matches) - .map_err(|e| CliError::Validation(e.to_string()))?; - - // Walk the subcommand tree to find the target method - let (method, matched_args) = resolve_method_from_matches(&doc, &matches)?; - - let params_override = matched_args - .get_one::("params") - .map(|s| s.as_str()); - let params = 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) - }; - // Resolve the configured `x-fern-global-headers` (CLI > env > - // default) and check that required ones have a value, deferring - // to per-op overrides where the operation declares a header - // parameter with the same wire-name. Built once per invocation - // and stamped on every outgoing request inside the executor. - let global_header_overrides = - build_global_header_overrides(matched_args, &doc, method, ¶ms)?; - let body_json = matched_args - .try_get_one::("json") - .ok() - .flatten() - .map(|s| s.as_str()); - // The binary-body flag name is per-operation (driven by - // `x-fern-parameter-name` or the schema's `format: binary` default). - // Look it up only for methods that declare one. The raw value is - // parsed by the executor into one of three forms — plain path, - // `@`, or `-` for stdin — so we only reject control characters - // here (and only on the path-bearing forms). - 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| (b.flag_name.clone(), s.as_str())) - }); - if let Some((ref flag, p)) = binary_body_path { - let stripped = p.strip_prefix('@').unwrap_or(p); - if stripped != "-" { - crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; - } - } - let binary_body_path = binary_body_path.as_ref().map(|(_, p)| *p); - let output_path = matched_args - .get_one::("output") - .map(|s| s.as_str()); - - // Validate file paths against traversal - let output_path_buf = if let Some(p) = output_path { - Some(crate::validate::validate_safe_file_path(p, "--output")?) - } else { - None - }; - let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); - - let dry_run = matched_args.get_flag("dry-run"); - - // Build pagination config with API-specific token names - let pagination = build_pagination_config(matched_args, &doc); - - // Build the auth provider once, from the registered bindings - // lowered against the spec's `components.securitySchemes`. - let auth_provider = self.build_auth_provider(&doc); - - // --base-url flag wins; otherwise {NAME}_BASE_URL env var. - let base_url_override_owned = cli_args::resolve_base_url_override(&matches, &self.name)?; - let base_url_override = base_url_override_owned.as_deref(); - - // Honor `x-fern-sdk-return-value` extraction unless the caller - // passes `--no-extract`. The flag is a debugging escape hatch - // that prints the full response body; matches the upstream - // behavior of falling back to the raw response when the SDK - // can't (or shouldn't) project to the named property. - let no_extract = matched_args.get_flag("no-extract"); - - // Honor `--no-retry` as a debug-only opt-out. When set, the - // executor skips the retry wrapper regardless of the operation's - // `x-fern-retries` policy — including transient network errors — - // so failures surface immediately. Aligns with the open design - // question called out in the FER-9864 PR description. - let no_retry = matched_args.get_flag("no-retry"); - - // `--no-stream` is only registered on operations with - // `x-fern-streaming` (see `build_method_command`). Use - // `try_get_one` so the flag-absent case is a clean false - // rather than a panic on unknown-arg lookup. - let no_stream = matched_args - .try_get_one::("no-stream") - .ok() - .flatten() - .copied() - .unwrap_or(false); - - // Execute - executor::execute_method( - &doc, - method, - params_json, - body_json, - &auth_provider, - output_path, - None, // no upload - binary_body_path, - dry_run, - &pagination, - &pipeline, - false, - base_url_override, - &http_config, - no_extract, - no_retry, - no_stream, - &global_header_overrides, - ) - .await - .map(|_| ()) + 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. - fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); crate::auth::build_provider_with_strategy( &self.auth_bindings, @@ -1642,24 +1272,88 @@ impl CliApp { has_per_endpoint, ) } + + /// 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); + crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ) + } +} + +/// 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)>, } /// Runtime context passed to custom command handlers. /// -/// Provides access to the loaded API spec, the constructed auth provider, -/// and a convenience method for executing API methods. +/// 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 { - doc: RestDescription, - auth_provider: DynAuthProvider, - http_config: crate::http::HttpConfig, - /// Resolved `x-fern-global-headers` for this CLI invocation - /// (CLI flag > env var > default, computed up front in `run_async`). - /// Per-op overrides are applied at the call site of `execute_method` - /// — see [`AppContext::extra_headers_for`]. - global_headers: Vec<(String, String)>, + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + quiet: bool, } impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers }], + quiet: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + 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` @@ -1673,10 +1367,21 @@ impl AppContext { /// 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) @@ -1687,12 +1392,12 @@ impl AppContext { // 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 = self + 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(&self.doc.global_headers, method, ¶ms, |h| { + 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()) @@ -1700,7 +1405,7 @@ impl AppContext { } /// Execute an API method by name, using the same executor as built-in - /// commands. + /// commands. Automatically routes to the binding that owns `method`. pub fn execute( &self, method: &RestMethod, @@ -1708,16 +1413,17 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() @@ -1727,8 +1433,9 @@ impl AppContext { let pipeline = formatter::OutputPipeline { format: output_format.clone(), color_mode: formatter::ColorMode::default(), + quiet: self.quiet, }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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 @@ -1736,11 +1443,11 @@ impl AppContext { // `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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, None, @@ -1749,7 +1456,7 @@ impl AppContext { &pipeline, false, None, - &self.http_config, + &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 @@ -1782,7 +1489,7 @@ impl AppContext { /// /// 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. a multipart file upload). Designed for + /// binary request body (e.g. a file upload endpoint). Designed for /// custom commands that chain multiple API calls. pub fn invoke( &self, @@ -1791,32 +1498,33 @@ impl AppContext { 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: self + token_query_param: entry .doc .pagination_token_query_param .clone() .unwrap_or_else(|| "pageToken".to_string()), - token_response_path: self + token_response_path: entry .doc .pagination_token_response_path .clone() .unwrap_or_else(|| "nextPageToken".to_string()), }; - let extra_headers = self.extra_headers_for(method, params_json)?; + let extra_headers = self.extra_headers_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( - &self.doc, + &entry.doc, method, params_json, body_json, - &self.auth_provider, + &entry.auth_provider, None, None, binary_body_path, @@ -1825,7 +1533,7 @@ impl AppContext { &formatter::OutputPipeline::default(), true, // capture_output None, - &self.http_config, + &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. @@ -1853,8 +1561,42 @@ impl AppContext { } /// 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.doc + &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. @@ -1871,11 +1613,32 @@ impl AppContext { /// [`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.http_config + &self.entries[0].http_config } } +/// 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`. @@ -2307,35 +2070,6 @@ mod tests { )); } - #[test] - fn test_cli_app_custom_command() { - fn handler( - _matches: &clap::ArgMatches, - _ctx: &AppContext, - ) -> Result<(), CliError> { - Ok(()) - } - - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") - .command(clap::Command::new("custom"), handler); - - assert_eq!(app.custom_commands.len(), 1); - assert!(app.custom_commands.entries()[0].0.is_empty()); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "custom"); - } - - #[test] - fn test_cli_app_command_under_records_path() { - fn handler(_m: &clap::ArgMatches, _c: &AppContext) -> Result<(), CliError> { Ok(()) } - let app = CliApp::new("test") - .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") - .command_under(&["webhooks"], clap::Command::new("verify"), handler); - assert_eq!(app.custom_commands.len(), 1); - assert_eq!(app.custom_commands.entries()[0].0, vec!["webhooks".to_string()]); - assert_eq!(app.custom_commands.entries()[0].1.get_name(), "verify"); - } - #[test] fn test_resolve_method_from_matches_basic() { let mut resources = std::collections::HashMap::new(); @@ -2430,15 +2164,15 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), + 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. - global_headers: Vec::new(), - }; + Vec::new(), + ); let method = RestMethod::default(); let err = ctx.extra_headers_for(&method, None).unwrap_err(); let msg = format!("{err}"); @@ -2469,12 +2203,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + ); let mut parameters: HashMap = HashMap::new(); parameters.insert( "X-API-Stage".into(), @@ -2512,12 +2246,12 @@ mod tests { }], ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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:?}"); @@ -2599,12 +2333,12 @@ mod tests { parameters, ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, - auth_provider: crate::auth::no_auth_provider(), - http_config: crate::http::HttpConfig::new("test").unwrap(), - global_headers: Vec::new(), - }; + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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. @@ -2698,13 +2432,84 @@ mod tests { name: "test".to_string(), ..Default::default() }; - let ctx = AppContext { + let ctx = AppContext::new( doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + 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(), + ); + 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(), - }; - assert_eq!(ctx.spec().name, "test"); + }); + + // 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] @@ -3122,7 +2927,7 @@ paths: #[test] fn test_merge_schemas_first_write_wins_on_duplicate() { // Multi-spec setups commonly share schema names (`ErrorResponse`, - // `Pagination`). A strict-error policy makes such setups + // `Pagination`). Strict-error policy made multi-spec use // unworkable; first-write-wins lets specs share without manual // de-duplication. let mut acc = HashMap::new(); @@ -3208,8 +3013,8 @@ paths: #[test] fn test_spec_under_merges_multiple_specs_into_same_prefix() { // Two specs sharing a prefix should merge under it (not error). - // Supports use cases where many specs all need to live under a - // single namespace (e.g. a versioned `v2` group). + // Prevents 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" } diff --git a/seed/cli/x-fern-default/src/openapi/binding.rs b/seed/cli/x-fern-default/src/openapi/binding.rs new file mode 100644 index 000000000000..afce90e0a08c --- /dev/null +++ b/seed/cli/x-fern-default/src/openapi/binding.rs @@ -0,0 +1,597 @@ +//! [`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::sync::Arc; + +use crate::auth::{AuthCredentialSource, 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>>, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(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 + } + + /// 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 + } + + /// 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(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + }) + } + + /// 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 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. basic auth + // but not the OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + 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"), + ); + } + + Ok(cli) + } + + fn render_json_help( + &self, + subcommand_path: &[String], + out: &mut dyn std::io::Write, + ) -> Result { + let prepared = self.ensure_prepared()?; + match super::help::write_json_help(&prepared.doc, subcommand_path, out) { + Ok(()) => Ok(true), + // "Resource not found" / "Operation not found" means the path + // belongs to a different binding — return false so the + // dispatch_pipeline loop tries the next one. + Err(CliError::Validation(msg)) + if msg.contains("not found") => + { + Ok(false) + } + Err(e) => Err(e), + } + } + + 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) }), + }; + + // Intercept `generate-skills` — it's not a spec operation. + if _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. + let (method, matched_args) = + super::resolve_method_from_matches(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(); + + let dry_run = matched_args.get_flag("dry-run"); + + let pagination = super::app::build_pagination_config(matched_args, doc); + + 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. + if let Some(path_str) = binary_body_path { + let stripped = path_str.strip_prefix('@').unwrap_or(path_str); + if stripped != "-" { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + crate::output::reject_dangerous_chars(stripped, &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_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(); + + // Read --output flag for binary response file writing. + // validate_safe_file_path rejects traversal, symlink escapes, + // and control characters per AGENTS.md. + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = if let Some(ref p) = output_path_owned { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + // Execute with capture_output = true to get the Value back + // instead of printing to stdout. + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + dry_run, + &pagination, + &crate::formatter::OutputPipeline::default(), + true, // capture_output = true + base_url_override, + &prepared.http_config, + no_extract, + no_retry, + no_stream, + &global_header_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 ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + 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); + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + 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, + ).with_quiet(quiet); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + ).with_quiet(quiet); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/seed/cli/x-fern-default/src/openapi/commands.rs b/seed/cli/x-fern-default/src/openapi/commands.rs index 296ffd353adb..c5d3897cc368 100644 --- a/seed/cli/x-fern-default/src/openapi/commands.rs +++ b/seed/cli/x-fern-default/src/openapi/commands.rs @@ -98,6 +98,7 @@ pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ "no-extract", "no-retry", "no-stream", + "quiet", "help", ]; @@ -151,6 +152,14 @@ pub fn build_cli(doc: &RestDescription) -> Command { .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), ); // Add resource subcommands @@ -296,8 +305,8 @@ fn build_resource_command( method_cmd = method_cmd.arg( Arg::new("json") .long("json") - .help("JSON request body") - .value_name("JSON"), + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), ); } diff --git a/seed/cli/x-fern-default/src/openapi/discovery.rs b/seed/cli/x-fern-default/src/openapi/discovery.rs index f50cd56a4583..3f67f8a2228a 100644 --- a/seed/cli/x-fern-default/src/openapi/discovery.rs +++ b/seed/cli/x-fern-default/src/openapi/discovery.rs @@ -213,6 +213,52 @@ pub struct SdkVariable { 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 @@ -526,6 +572,12 @@ pub struct RestMethod { /// type. #[serde(default)] pub binary_request_body: Option, + /// 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 @@ -951,6 +1003,11 @@ 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, @@ -959,6 +1016,16 @@ pub struct JsonSchema { 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>, } @@ -968,6 +1035,9 @@ pub struct JsonSchema { 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, @@ -980,6 +1050,34 @@ pub struct JsonSchemaProperty { 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>, } diff --git a/seed/cli/x-fern-default/src/openapi/executor.rs b/seed/cli/x-fern-default/src/openapi/executor.rs index 2dcbd2499a4e..2af619a5c605 100644 --- a/seed/cli/x-fern-default/src/openapi/executor.rs +++ b/seed/cli/x-fern-default/src/openapi/executor.rs @@ -16,8 +16,8 @@ use tokio::io::AsyncWriteExt; use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; use crate::error::CliError; use crate::openapi::discovery::{ - MethodParameter, PaginationConfig as EndpointPagination, RestDescription, RestMethod, - RetriesConfig, StreamingConfig, + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, }; /// Resolved source for a binary request body (octet-stream uploads etc.). @@ -366,6 +366,11 @@ fn parse_and_validate_inputs( for (param_name, param_def) in &method.parameters { if param_def.required && !params.contains_key(param_name) { + // 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; + } let hint = missing_param_hint(param_def, param_name); return Err(CliError::Validation(format!( "Required parameter '{param_name}' is missing. {hint}" @@ -689,14 +694,12 @@ async fn build_http_request( } } } else if let Some(ref body_val) = input.body { - request = request.header("Content-Type", "application/json"); - request = request.json(body_val); + 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 = request.header("Content-Type", "application/json"); - request = request.json(body_val); + request = encode_request_body(request, body_val, &method.body_encoding); } Ok(request) @@ -1017,11 +1020,8 @@ async fn handle_json_response( return Ok(true); } } - } else { - // Not valid JSON, output as-is - if !capture_output && !body_text.is_empty() { - println!("{body_text}"); - } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); } Ok(false) @@ -1508,6 +1508,11 @@ pub async fn execute_method( }; if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; let mut dry_run_info = json!({ "dry_run": true, "url": input.full_url, @@ -1517,6 +1522,14 @@ pub async fn execute_method( "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 @@ -2448,6 +2461,69 @@ fn set_nested_value(obj: &mut Map, path: &str, value: 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 @@ -6005,8 +6081,8 @@ mod tests { #[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. upload - // endpoints land on the general API host instead of the upload host). + // If this is broken, requests route to the wrong host (e.g. uploads + // go to api.example.com instead of upload.example.com). let doc = RestDescription { root_url: "https://api.example.com/".to_string(), service_path: "v1/".to_string(), diff --git a/seed/cli/x-fern-default/src/openapi/help.rs b/seed/cli/x-fern-default/src/openapi/help.rs index dcd21282397d..9e7c263ddbb2 100644 --- a/seed/cli/x-fern-default/src/openapi/help.rs +++ b/seed/cli/x-fern-default/src/openapi/help.rs @@ -1,6 +1,6 @@ //! JSON help output — renders `--help --format json` as a machine-readable //! schema. When an agent passes both `--help` (or `-h`) and `--format json`, -//! `app.rs` intercepts before clap parses and calls [`render_json_help`]. +//! the pipeline intercepts before clap parses and calls [`render_json_help`]. use serde_json::{json, Map, Value}; @@ -8,7 +8,17 @@ use crate::error::CliError; use crate::openapi::discovery::{RestDescription, RestMethod, RestResource}; /// Renders JSON help for the given subcommand path and prints it to stdout. -pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { +#[cfg(test)] +pub(crate) fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), CliError> { + write_json_help(doc, path, &mut std::io::stdout()) +} + +/// Writer-parameterized variant of [`render_json_help`]. +pub(crate) fn write_json_help( + doc: &RestDescription, + path: &[String], + out: &mut dyn std::io::Write, +) -> Result<(), CliError> { let output = match path.len() { 0 => list_all_operations(doc), 1 => list_resource_operations(doc, &path[0])?, @@ -27,11 +37,13 @@ pub fn render_json_help(doc: &RestDescription, path: &[String]) -> Result<(), Cl } }; - println!( + writeln!( + out, "{}", serde_json::to_string_pretty(&output) .map_err(|e| CliError::Validation(format!("Failed to serialize help: {e}")))? - ); + ) + .map_err(|e| CliError::Other(e.into()))?; Ok(()) } diff --git a/seed/cli/x-fern-default/src/openapi/mod.rs b/seed/cli/x-fern-default/src/openapi/mod.rs index d2a8c492bbc7..cdc657e97ca8 100644 --- a/seed/cli/x-fern-default/src/openapi/mod.rs +++ b/seed/cli/x-fern-default/src/openapi/mod.rs @@ -1,11 +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, CliApp, HandlerFn, resolve_method_from_matches}; +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/seed/cli/x-fern-default/src/openapi/overlay.rs b/seed/cli/x-fern-default/src/openapi/overlay.rs index bc400a000026..85659b5da950 100644 --- a/seed/cli/x-fern-default/src/openapi/overlay.rs +++ b/seed/cli/x-fern-default/src/openapi/overlay.rs @@ -1832,7 +1832,7 @@ actions: #[test] fn test_overlay_on_fixture_spec() { - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: @@ -1891,7 +1891,7 @@ actions: fn test_overlay_on_fixture_spec_builds_cli_app() { use crate::openapi::CliApp; - let spec = include_str!("__fixtures__/openapi.json"); + let spec = include_str!("../../cli/openapi-fixture/openapi.yaml"); let overlay = r#" overlay: "1.0.0" info: diff --git a/seed/cli/x-fern-default/src/openapi/parser.rs b/seed/cli/x-fern-default/src/openapi/parser.rs index afa5c19dd96f..3cacb875f088 100644 --- a/seed/cli/x-fern-default/src/openapi/parser.rs +++ b/seed/cli/x-fern-default/src/openapi/parser.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Deserializer}; use crate::text::to_kebab_flag; use crate::openapi::discovery::{ - Availability, BinaryRequestBody, GlobalHeader, IdempotencyHeader, JsonSchema, + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, IdempotencyHeader, JsonSchema, JsonSchemaProperty, MethodParameter, PaginationConfig, RestDescription, RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, SecurityScheme, StreamingConfig, @@ -17,8 +17,8 @@ use crate::openapi::discovery::{ 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; some specs use the scalar -/// form while internal fixtures use the list form for nesting. +/// strings. The Fern extension allows both forms; some specs 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>, @@ -158,6 +158,13 @@ struct OpenApiSpec { 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 @@ -574,13 +581,125 @@ struct OpenApiMediaType { schema: 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)] +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)] #[serde(rename_all = "camelCase")] struct OpenApiSchemaObject { #[serde(rename = "$ref")] schema_ref: Option, - #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] - schema_type: 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)] properties: HashMap, @@ -589,6 +708,58 @@ struct OpenApiSchemaObject { 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 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, @@ -599,6 +770,59 @@ struct OpenApiSchemaObject { 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> @@ -619,13 +843,7 @@ where fn visit_seq>(self, mut seq: A) -> Result { let mut values = Vec::new(); while let Some(v) = seq.next_element::()? { - let s = 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:?}"), - }; - values.push(s); + values.push(yaml_scalar_to_string(&v)); } Ok(Some(values)) } @@ -1500,6 +1718,50 @@ fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { // 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); @@ -1517,12 +1779,16 @@ fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { JsonSchema { id: None, - schema_type: obj.schema_type.clone(), + 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() @@ -1546,7 +1812,8 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { .collect(); JsonSchemaProperty { - prop_type: obj.schema_type.clone(), + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), description: obj.description.clone(), schema_ref: None, format: obj.format.clone(), @@ -1554,7 +1821,16 @@ fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { properties, read_only: obj.read_only, default: None, - enum_values: obj.enum_values.clone(), + 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() @@ -2005,6 +2281,17 @@ pub fn load_openapi_spec_from_value( }) .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 @@ -2240,7 +2527,7 @@ pub fn load_openapi_spec_from_value( // 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_params) = extract_request_body( + let (request, binary_request_body, body_encoding, body_params) = extract_request_body( &operation.request_body, operation.operation_id.as_deref().unwrap_or("unknown"), &mut doc.schemas, @@ -2366,6 +2653,7 @@ pub fn load_openapi_spec_from_value( root_url: method_root_url, servers: method_servers, binary_request_body, + body_encoding, security_requirements, pagination, availability, @@ -2433,10 +2721,11 @@ fn insert_method_into_resources( /// the only way to supply them. const MAX_BODY_DEPTH: u8 = 3; -/// Returns `(json_schema, binary_body, body_params)`: +/// Returns `(json_schema, binary_body, body_encoding, body_params)`: /// - `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 @@ -2446,12 +2735,12 @@ fn extract_request_body( operation_id: &str, schemas: &mut HashMap, component_schemas: &HashMap, -) -> (Option, Option, HashMap) { +) -> (Option, Option, BodyEncoding, HashMap) { let Some(body) = request_body.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let Some(content) = body.content.as_ref() else { - return (None, None, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; if let Some(media) = content.get("application/json") { @@ -2469,6 +2758,7 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } @@ -2485,19 +2775,57 @@ fn extract_request_body( ..Default::default() }), None, + BodyEncoding::Json, body_params, ); } } - // No JSON body declared — look for a binary content type. Form bodies - // (`application/x-www-form-urlencoded`, `multipart/form-data`) need their - // own flag UX and are explicitly excluded here. + // No JSON 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, + ); + } + + 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, + ); + } + } + + // No JSON or form body — look for a binary content type. `multipart/form-data` + // is explicitly excluded (separate future work). 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, HashMap::new()); + return (None, None, BodyEncoding::Json, HashMap::new()); }; let is_binary_format = media @@ -2525,6 +2853,7 @@ fn extract_request_body( content_type: content_type.clone(), flag_name, }), + BodyEncoding::Json, HashMap::new(), ) } @@ -2549,7 +2878,7 @@ fn flatten_body_params_prefix( prefix: &str, ) -> HashMap { let mut out = HashMap::new(); - if depth >= MAX_BODY_DEPTH || schema.schema_type.as_deref() != Some("object") { + if depth >= MAX_BODY_DEPTH || schema.schema_type() != Some("object") { return out; } let required: std::collections::HashSet<&str> = @@ -2568,7 +2897,7 @@ fn flatten_body_params_prefix( if let Some(ref_path) = &prop.schema_ref { let ref_name = strip_ref_prefix(ref_path); if let Some(resolved) = component_schemas.get(&ref_name) { - if resolved.schema_type.as_deref() == Some("object") { + if resolved.schema_type() == Some("object") { let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); if !nested.is_empty() { out.extend(nested); @@ -2576,20 +2905,26 @@ fn flatten_body_params_prefix( } } // Non-object ref or depth limit reached (empty recursion) — emit with resolved type. - let is_array = resolved.schema_type.as_deref() == Some("array"); + 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 { - resolved.schema_type.clone() + resolved.schema_type().map(str::to_string) }, description: prop.description.clone().or_else(|| resolved.description.clone()), location: Some("body".to_string()), - required: required.contains(name.as_str()), + // 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: resolved.enum_values.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2599,7 +2934,7 @@ fn flatten_body_params_prefix( continue; } - let prop_type = prop.schema_type.as_deref(); + let prop_type = prop.schema_type(); // Nested object: recurse to emit dot-notation flags. If nothing comes // back (no sub-properties or depth limit hit), fall through to the default insert below. @@ -2612,19 +2947,21 @@ fn flatten_body_params_prefix( } 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 { - prop.schema_type.clone() + prop_type.map(str::to_string) }, description: prop.description.clone(), location: Some("body".to_string()), - required: required.contains(name.as_str()), + required: required.contains(name.as_str()) && const_default.is_none(), format: prop.format.clone(), - enum_values: prop.enum_values.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, repeated: is_array, ..Default::default() }, @@ -2641,6 +2978,7 @@ fn flatten_body_params_prefix( mod tests { use super::*; + #[test] fn test_camel_to_kebab() { assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); @@ -2732,7 +3070,7 @@ mod tests { #[test] fn test_strip_tag_prefix_no_strip_when_no_overlap() { - // When op `getCustomers` doesn't start with tag tokens, keep verbatim. + // When op `getCustomers` doesn't start with tag tokens. assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); } @@ -2757,8 +3095,8 @@ paths: #[test] fn test_method_name_keeps_operation_id_when_no_tag_overlap() { - // operationId doesn't start with tag → method stays as full kebab'd - // operationId. Matches Fern's behavior. + // When 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" } @@ -2858,8 +3196,8 @@ paths: #[test] fn test_group_name_accepts_scalar_string() { - // Some Fern specs write `x-fern-sdk-group-name: transcripts` as a bare - // string; the parser should accept it as a single-element list. + // Some 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" } @@ -7708,4 +8046,547 @@ paths: vec!["public".to_string(), "public".to_string()], ); } + // -- 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")); + } + + // -- 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() { + // 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_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"), + ); + } } diff --git a/seed/cli/x-fern-default/src/openapi/skill_emitter.rs b/seed/cli/x-fern-default/src/openapi/skill_emitter.rs new file mode 100644 index 000000000000..aecee7c01b96 --- /dev/null +++ b/seed/cli/x-fern-default/src/openapi/skill_emitter.rs @@ -0,0 +1,731 @@ +//! 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 flags + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!(out, "These flags are available on every command:\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` | `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 | |" + ); + 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-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(_) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + 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, "Before calling any API method, inspect it:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Browse resources and methods"); + let _ = writeln!(out, "{bin_name} {group_name} --help\n"); + let _ = writeln!(out, "# Machine-readable operation list"); + let _ = writeln!(out, "{bin_name} {group_name} --help --format json"); + 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("--help --format json")); + } + + #[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/seed/cli/x-fern-default/src/stability.rs b/seed/cli/x-fern-default/src/stability.rs new file mode 100644 index 000000000000..82a0536b7f60 --- /dev/null +++ b/seed/cli/x-fern-default/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/seed/cli/x-fern-default/tests/auth_routing_wire.rs b/seed/cli/x-fern-default/tests/auth_routing_wire.rs deleted file mode 100644 index f7d2f0be6b58..000000000000 --- a/seed/cli/x-fern-default/tests/auth_routing_wire.rs +++ /dev/null @@ -1,728 +0,0 @@ -/// Wire test for the spec-aware auth provider architecture. -/// -/// Two security schemes (HTTP bearer + apiKey-in-header) registered on a -/// hand-built `RestDescription`, with three methods exercising distinct -/// requirement shapes: -/// -/// - `things.list` requires only `bearerAuth` → `Authorization: Bearer ...`. -/// - `things.update` requires only `apiKey` → `X-Api-Key: ...`. -/// - `things.ping` declares no `security_requirements` → falls back to the -/// `AnyAuthProvider` default, which tries the bindings in registration -/// order; the bearer binding wins. -/// -/// Each test mounts an `expect(1)` mock that *only* matches the expected -/// header. A wrong header on the wire would miss the mock, get a 404 from -/// the catch-all, and surface as a test failure — wiremock panics at drop -/// time on unfulfilled `expect(1)` mocks. -use std::collections::HashMap; - -use fern_cli_sdk::auth::{ - build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, - finalize_bindings, AuthCredentialSource, AuthStrategy, DynAuthProvider, EndpointAuthMetadata, - SchemeBinding, -}; -use std::sync::Arc; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::discovery::{ - RestDescription, RestMethod, RestResource, SecurityScheme, -}; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use serde_json::json; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const BEARER_TOKEN: &str = "bearer-secret"; -const API_KEY: &str = "apikey-secret"; - -/// Build a `RestDescription` with two declared security schemes and three -/// methods that exercise routing, anonymous, and fallback paths. -fn build_doc(server_url: &str) -> RestDescription { - let mut doc = RestDescription { - name: "auth-routing-fixture".to_string(), - version: "1.0".to_string(), - root_url: server_url.to_string(), - ..Default::default() - }; - doc.security_schemes - .insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); - doc.security_schemes.insert( - "apiKey".to_string(), - SecurityScheme::ApiKeyHeader { - name: "X-Api-Key".to_string(), - }, - ); - - let mut things = RestResource::default(); - - // list — requires bearerAuth - let mut list_req = HashMap::new(); - list_req.insert("bearerAuth".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - RestMethod { - id: Some("things.list".to_string()), - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![list_req]), - ..Default::default() - }, - ); - - // update — requires apiKey only - let mut update_req = HashMap::new(); - update_req.insert("apiKey".to_string(), Vec::::new()); - things.methods.insert( - "update".to_string(), - RestMethod { - id: Some("things.update".to_string()), - http_method: "PUT".to_string(), - path: "/things".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(vec![update_req]), - ..Default::default() - }, - ); - - // ping — no security requirements declared - things.methods.insert( - "ping".to_string(), - RestMethod { - id: Some("things.ping".to_string()), - http_method: "GET".to_string(), - path: "/ping".to_string(), - root_url: server_url.to_string(), - security_requirements: None, - ..Default::default() - }, - ); - - // health — explicit anonymous (`security: []`). Distinct from `ping` - // (which simply omits the security block): the empty array opts the - // endpoint *out* of every scheme, even when a default is bound. - things.methods.insert( - "health".to_string(), - RestMethod { - id: Some("things.health".to_string()), - http_method: "GET".to_string(), - path: "/health".to_string(), - root_url: server_url.to_string(), - security_requirements: Some(Vec::new()), - ..Default::default() - }, - ); - - doc.resources.insert("things".to_string(), things); - doc -} - -/// Bind both schemes, ordered bearer-first so the AnyAuth fallback prefers it. -fn bindings() -> Vec<(String, SchemeBinding)> { - vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ] -} - -fn http_config() -> HttpConfig { - HttpConfig::new("auth-routing-fixture").unwrap() -} - -fn pagination() -> PaginationConfig { - PaginationConfig::default() -} - -async fn run( - doc: &RestDescription, - method_name: &str, - provider: &DynAuthProvider, -) -> Result, fern_cli_sdk::error::CliError> { - let m = doc.resources["things"].methods[method_name].clone(); - executor::execute_method( - doc, - &m, - None, - None, - provider, - None, - None, - None, - false, - &pagination(), - &OutputPipeline::default(), - true, // capture_output (don't print to stdout) - None, - &http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await -} - -#[tokio::test] -async fn test_routing_endpoint_requires_bearer_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!( - result.is_ok(), - "list call failed: {:?}", - result.err() - ); -} - -#[tokio::test] -async fn test_routing_endpoint_requires_apikey_only() { - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("PUT")) - .and(path("/things")) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "update", &provider).await; - assert!(result.is_ok(), "update call failed: {:?}", result.err()); - - // wiremock's header matchers only see headers that exist — they can't - // assert a header is *absent*. Inspect the actual recorded request to - // pin down that no Authorization leaked into the apiKey-only endpoint. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1, "exactly one request expected"); - let req = &recorded[0]; - assert_eq!( - req.headers - .get("X-Api-Key") - .and_then(|v| v.to_str().ok()), - Some(API_KEY), - "apiKey header value should match", - ); - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT be present on apiKey-only endpoint, got: {:?}", - req.headers.get("Authorization"), - ); -} - -#[tokio::test] -async fn test_routing_anonymous_endpoint_uses_any_auth_fallback() { - // `ping` has no security requirements. The RoutingAuthProvider should - // fall through to its `default` (AnyAuthProvider), which tries the - // bindings in registration order — bearer first → Authorization wins. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/ping")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"pong": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "ping", &provider).await; - assert!(result.is_ok(), "ping failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_routing_explicit_anonymous_endpoint_sends_no_auth_headers() { - // `health` declares `security: []` — the operation explicitly opts out - // of every scheme. Both bindings are present and have credentials, but - // neither header may land on the wire. The unit test at - // `compose.rs:399` pins the same behavior in isolation; this is the - // end-to-end version covering the executor + RoutingAuthProvider path. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/health")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "health", &provider).await; - assert!(result.is_ok(), "health call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "Authorization header must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT leak onto explicit-anonymous endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -#[tokio::test] -async fn test_bearer_required_endpoint_unauthorized_when_no_bearer_binding() { - // Only the apiKey scheme is bound. The bearer-required `list` endpoint - // can't satisfy any requirement → request goes out unauthed → server - // returns 401 → executor surfaces the friendly "no creds" Auth error, - // because `RoutingAuthProvider::has_credentials_for(endpoint)` - // recognizes that this specific endpoint's bearer requirement isn't - // satisfied (even though apiKey *is* bound elsewhere). - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let only_apikey = vec![( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - )]; - let provider = build_provider_from_doc(&doc, &only_apikey); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!( - msg.contains("Access denied"), - "expected friendly 'Access denied' message, got: {msg}", - ); - } - other => panic!("expected friendly CliError::Auth, got: {other:?}"), - } - - // Critical security guard: even though no requirement was satisfiable, - // the apiKey we have must NOT have been opportunistically attached. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!(req.headers.get("Authorization").is_none()); - assert!(req.headers.get("X-Api-Key").is_none()); -} - -// -------- AuthStrategy::All (Phase 9) -------- - -#[tokio::test] -async fn test_strategy_all_attaches_every_scheme_to_every_request() { - // Generator-driven scenario: API requires bearer + apiKey on every - // request, regardless of what the spec says about per-endpoint - // security. `auth_strategy(All)` is how the generator expresses this. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(API_KEY)), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, // doc has per-endpoint security; All overrides anyway - ); - assert_eq!(provider.name(), "all"); - - // Even though `things.list` declares only bearerAuth in its - // security_requirements, the All strategy ignores that and attaches - // both schemes — that's the whole point. - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", &format!("Bearer {BEARER_TOKEN}")[..])) - .and(header("X-Api-Key", API_KEY)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_strategy_all_friendly_error_when_any_scheme_missing() { - // All-auth means one missing scheme = no auth attempted. The friendly - // error should fire because we couldn't fully satisfy the requirement. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![ - ( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::literal(BEARER_TOKEN)), - ), - ( - "apiKey".to_string(), - // Missing — so all-auth can't be satisfied. - SchemeBinding::Token(AuthCredentialSource::Missing), - ), - ]; - let provider = build_provider_with_strategy( - &bindings, - &doc.security_schemes, - AuthStrategy::All, - true, - ); - assert!(!provider.has_credentials()); - - Mock::given(method("GET")) - .and(path("/things")) - .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized")) - .expect(1) - .mount(&server) - .await; - - let err = run(&doc, "list", &provider).await.unwrap_err(); - match err { - fern_cli_sdk::error::CliError::Auth(msg) => { - assert!(msg.contains("Access denied"), "got: {msg}"); - } - other => panic!("expected friendly Auth error, got: {other:?}"), - } - - // No auth must have been attached — partial all-auth would leak - // whichever scheme *is* bound (here the bearer token) without - // satisfying the API's actual requirement. `AllAuthProvider::apply` - // short-circuits when `has_credentials_for(endpoint)` is false so - // nothing reaches the wire. - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("Authorization").is_none(), - "bearer token must NOT leak when all-auth can't be fully satisfied, got: {:?}", - req.headers.get("Authorization"), - ); - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present (apiKey binding is missing), got: {:?}", - req.headers.get("X-Api-Key"), - ); -} - -// -------- Compositional credential sources (Phase 7) -------- - -/// Simulate `clap` parsing `--api-token ` and produce the matches -/// the SDK would normally hand to `finalize_bindings`. Test-only helper. -fn matches_with_arg(arg_name: &'static str, value: Option<&str>) -> Arc { - let cmd = clap::Command::new("auth-routing-test").arg( - clap::Arg::new(arg_name) - .long(arg_name) - .num_args(1), - ); - let argv: Vec = match value { - Some(v) => vec![ - "auth-routing-test".to_string(), - format!("--{arg_name}"), - v.to_string(), - ], - None => vec!["auth-routing-test".to_string()], - }; - Arc::new(cmd.try_get_matches_from(argv).unwrap()) -} - -#[tokio::test] -async fn test_credential_source_cli_finalizes_and_routes() { - // Bind bearer to a CLI flag, simulate the user passing - // `--api-token cli-supplied`, and confirm the value lands on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::cli("api-token")), - )]; - let matches = matches_with_arg("api-token", Some("cli-supplied")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer cli-supplied")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_falls_back_through_sources() { - // Chain: --api-token (not supplied) → env var (set). The env var should - // win because the CLI source resolves to None when the flag wasn't - // passed, and Chain takes the first non-empty. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_FALLBACK"; - std::env::set_var(env_key, "from-env-fallback"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer from-env-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_chain_cli_wins_over_env() { - // Both CLI and env are set. CLI is registered first in the chain → CLI - // value wins. The standard "command-line overrides environment" - // precedence pattern. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let env_key = "FERN_CLI_AUTH_WIRE_TEST_PRECEDENCE"; - std::env::set_var(env_key, "loser-from-env"); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env(env_key), - ])), - )]; - let matches = matches_with_arg("api-token", Some("winner-from-cli")); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer winner-from-cli")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - std::env::remove_var(env_key); - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_file_reads_from_disk() { - // Write a credential to a temp file, bind the bearer scheme to it, - // confirm the trimmed file contents land on the wire. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("api-token"); - std::fs::write(&token_path, " file-secret \n").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::file(&token_path)), - )]; - // No CLI args needed; finalize is a no-op for File. - let matches = matches_with_arg("ignored", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer file-secret")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_credential_source_full_chain_cli_env_file() { - // Canonical "CLI > env > file" pattern. Only the file has a value, - // so the chain should resolve to the file's contents. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let dir = tempfile::tempdir().unwrap(); - let token_path = dir.path().join("token"); - std::fs::write(&token_path, "deepest-fallback").unwrap(); - let bindings = vec![( - "bearerAuth".to_string(), - SchemeBinding::Token(AuthCredentialSource::any([ - AuthCredentialSource::cli("api-token"), - AuthCredentialSource::from_env("FERN_CLI_AUTH_WIRE_FULL_CHAIN_DEFINITELY_UNSET"), - AuthCredentialSource::file(&token_path), - ])), - )]; - let matches = matches_with_arg("api-token", None); - let finalized = finalize_bindings(bindings, &matches); - let provider = build_provider_from_doc(&doc, &finalized); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Bearer deepest-fallback")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); -} - -#[tokio::test] -async fn test_basic_auth_with_per_field_chains() { - // HTTP basic with chains on each field — username from CLI, password - // from a file. Closes the loop on the "decoupled sources" pitch. - let server = MockServer::start().await; - let mut doc = fern_cli_sdk::openapi::discovery::RestDescription::default(); - doc.security_schemes.insert( - "basic".to_string(), - fern_cli_sdk::openapi::discovery::SecurityScheme::HttpBasic, - ); - let mut things = fern_cli_sdk::openapi::discovery::RestResource::default(); - let mut req_map = HashMap::new(); - req_map.insert("basic".to_string(), Vec::::new()); - things.methods.insert( - "list".to_string(), - fern_cli_sdk::openapi::discovery::RestMethod { - http_method: "GET".to_string(), - path: "/things".to_string(), - root_url: server.uri(), - security_requirements: Some(vec![req_map]), - ..Default::default() - }, - ); - doc.resources.insert("things".to_string(), things); - - let dir = tempfile::tempdir().unwrap(); - let pass_path = dir.path().join("pw"); - std::fs::write(&pass_path, "hunter2").unwrap(); - - let bindings = vec![( - "basic".to_string(), - SchemeBinding::Basic { - username: AuthCredentialSource::cli("user"), - password: AuthCredentialSource::file(&pass_path), - }, - )]; - - let cmd = clap::Command::new("test").arg( - clap::Arg::new("user") - .long("user") - .num_args(1), - ); - let matches = Arc::new( - cmd.try_get_matches_from(["test", "--user", "alice"]) - .unwrap(), - ); - let finalized = finalize_bindings(bindings, &matches); - // Doc has per-endpoint security so the wrapper is RoutingAuthProvider. - let provider = build_provider_from_bindings( - &finalized, - &doc.security_schemes, - true, - ); - - // base64("alice:hunter2") = YWxpY2U6aHVudGVyMg== - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", "Basic YWxpY2U6aHVudGVyMg==")) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let m = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &m, - None, - None, - &provider, - None, - None, - None, - false, - &PaginationConfig::default(), - &fern_cli_sdk::formatter::OutputPipeline::default(), - true, - None, - &fern_cli_sdk::http::HttpConfig::new("auth-routing-fixture").unwrap(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], - ) - .await; - assert!(result.is_ok(), "basic auth call failed: {:?}", result.err()); - - // Pin that the unused EndpointAuthMetadata import compiles. - let _ = EndpointAuthMetadata::unspecified(); -} - -#[tokio::test] -async fn test_bearer_only_endpoint_does_not_leak_apikey_header() { - // Symmetric guard for the bearer-only endpoint: even though the apiKey - // scheme is bound and has credentials, the operation's - // `security_requirements` pin bearer alone — X-Api-Key must not appear. - let server = MockServer::start().await; - let doc = build_doc(&server.uri()); - let provider = build_provider_from_doc(&doc, &bindings()); - - Mock::given(method("GET")) - .and(path("/things")) - .and(header("Authorization", format!("Bearer {BEARER_TOKEN}"))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) - .expect(1) - .mount(&server) - .await; - - let result = run(&doc, "list", &provider).await; - assert!(result.is_ok(), "list call failed: {:?}", result.err()); - - let recorded = server.received_requests().await.expect("requests recorded"); - assert_eq!(recorded.len(), 1); - let req = &recorded[0]; - assert!( - req.headers.get("X-Api-Key").is_none(), - "X-Api-Key must NOT be present on bearer-only endpoint, got: {:?}", - req.headers.get("X-Api-Key"), - ); -} diff --git a/seed/cli/x-fern-default/tests/common/mod.rs b/seed/cli/x-fern-default/tests/common/mod.rs deleted file mode 100644 index b269c5dfab9c..000000000000 --- a/seed/cli/x-fern-default/tests/common/mod.rs +++ /dev/null @@ -1,260 +0,0 @@ -// This module is shared across multiple `tests/*.rs` integration binaries -// via `mod common`. Each binary uses a different subset of these helpers, -// so per-binary dead-code lints fire on the unused leftovers. Suppress -// at the module level rather than peppering every item with attributes. -#![allow(dead_code)] - -use serde_json::Value; -use wiremock::matchers::{header_regex, method, path_regex}; -use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate}; - -/// Canonical path-parameter values matching the openapi-fixture-mappings.json stubs. -pub struct OpenApiFixtures; - -impl OpenApiFixtures { - pub const FILE_ID: &'static str = "file-1"; - pub const FOLDER_ID: &'static str = "folder-1"; - pub const USER_ID: &'static str = "user-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Canonical values for the graphql-fixture wire tests. -pub struct GraphqlFixtures; - -impl GraphqlFixtures { - pub const NODE_ID: &'static str = "node-1"; - pub const TOKEN: &'static str = "test-token"; -} - -/// Matches when the JSON body's `variables` object contains all specified key-value pairs -/// (subset match — extra keys are allowed). Use in GraphQL tier-2 wire tests. -pub struct BodyVariablesContain(pub Value); - -impl Match for BodyVariablesContain { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables") else { - return false; - }; - let Some(expected) = self.0.as_object() else { - return false; - }; - for (key, expected_val) in expected { - if vars.get(key) != Some(expected_val) { - return false; - } - } - true - } -} - -/// Matches when none of the named keys appear in the JSON body's `variables` object. -/// Use to assert that the CLI did not auto-emit a variable the user never supplied. -pub struct BodyVariablesAbsent(pub &'static [&'static str]); - -impl Match for BodyVariablesAbsent { - fn matches(&self, request: &Request) -> bool { - let Ok(body) = serde_json::from_slice::(&request.body) else { - return false; - }; - let Some(vars) = body.get("variables").and_then(|v| v.as_object()) else { - // No variables block at all — every key is trivially absent. - return true; - }; - self.0.iter().all(|k| !vars.contains_key(*k)) - } -} - -/// Matches any request whose body contains a `"query"` key (minimal GraphQL check). -pub struct IsGraphqlRequest; - -impl Match for IsGraphqlRequest { - fn matches(&self, request: &Request) -> bool { - serde_json::from_slice::(&request.body) - .ok() - .and_then(|v| v.get("query").cloned()) - .is_some() - } -} - -/// Load all stubs from a WireMock mappings JSON string into an in-process -/// MockServer. This is the in-process equivalent of the Docker WireMock -/// approach, but with no external dependencies and per-test isolation. -/// -/// Loader rules: -/// - Method and path are always matched. -/// - `pathParameters` `equalTo` values are resolved into the path literal -/// so `/files/{file_id}` + `{file_id: "12345"}` becomes `/files/12345`. -/// - Remaining `{param}` placeholders become `[^/]+` wildcards. -/// - `Authorization: Bearer .+` is enforced when present in the mapping, -/// verifying the CLI sends auth on every real request. -/// - `queryParameters` and `bodyPatterns` are stripped — individual tests -/// that care about request shape add their own `expect(1)` mocks. -pub async fn mount_mappings(server: &MockServer, mappings_json: &str) { - let doc: serde_json::Value = - serde_json::from_str(mappings_json).expect("mappings JSON must be valid"); - - for mapping in doc["mappings"].as_array().expect("mappings must be array") { - let req = &mapping["request"]; - let resp = &mapping["response"]; - - let http_method = req["method"].as_str().unwrap_or("GET"); - let template = req - .get("urlPathTemplate") - .or_else(|| req.get("url")) - .and_then(|v| v.as_str()) - .unwrap_or("/"); - let status = resp["status"].as_u64().unwrap_or(200) as u16; - let body = resp["body"].as_str().unwrap_or(""); - - let resolved = resolve_path(template, req.get("pathParameters")); - let regex = template_to_path_regex(&resolved); - - let has_auth_check = req - .get("headers") - .and_then(|h| h.get("Authorization")) - .is_some(); - - // Propagate response headers so the CLI can correctly determine the - // response format. set_body_string() forces Content-Type: text/plain, - // so use set_body_json() for JSON responses — that way the CLI won't - // treat the body as a binary download. - let resp_content_type = resp - .get("headers") - .and_then(|h| h.get("Content-Type")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - let mut response = - if resp_content_type.contains("application/json") { - if let Ok(json_body) = serde_json::from_str::(body) { - ResponseTemplate::new(status).set_body_json(json_body) - } else { - ResponseTemplate::new(status).set_body_string(body) - } - } else { - ResponseTemplate::new(status).set_body_string(body) - }; - if let Some(headers) = resp.get("headers").and_then(|h| h.as_object()) { - for (name, value) in headers { - if name.to_lowercase() == "content-type" { - continue; // already handled by the body setter above - } - if let Some(v) = value.as_str() { - response = response.insert_header(name.as_str(), v); - } - } - } - - if has_auth_check { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .and(header_regex("Authorization", "Bearer .+")) - .respond_with(response) - .mount(server) - .await; - } else { - Mock::given(method(http_method)) - .and(path_regex(regex)) - .respond_with(response) - .mount(server) - .await; - } - } -} - -/// Substitute `{param}` placeholders with their `equalTo` canonical values -/// from the mapping's `pathParameters` block. -fn resolve_path(template: &str, path_params: Option<&serde_json::Value>) -> String { - let mut result = template.to_string(); - if let Some(obj) = path_params.and_then(|v| v.as_object()) { - for (param, matcher) in obj { - if let Some(value) = matcher.get("equalTo").and_then(|v| v.as_str()) { - result = result.replace(&format!("{{{param}}}"), value); - } - } - } - result -} - -/// Convert a path template (possibly still containing `{param}` placeholders) -/// into a full anchored regex string suitable for `path_regex(...)`. -fn template_to_path_regex(template: &str) -> String { - let mut result = String::from("^"); - let mut chars = template.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '{' { - // consume the placeholder name up to and including '}' - for c in chars.by_ref() { - if c == '}' { - break; - } - } - result.push_str("[^/]+"); - } else { - // escape regex metacharacters in literal path segments - match ch { - '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '^' | '$' | '|' | '\\' => { - result.push('\\'); - result.push(ch); - } - _ => result.push(ch), - } - } - } - result.push('$'); - result -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn resolve_path_substitutes_known_params() { - let params = serde_json::json!({"file_id": {"equalTo": "12345"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/12345" - ); - } - - #[test] - fn resolve_path_leaves_unknown_params() { - let params = serde_json::json!({"file_id": {"matches": "\\d+"}}); - assert_eq!( - resolve_path("/files/{file_id}", Some(¶ms)), - "/files/{file_id}" - ); - } - - #[test] - fn template_to_path_regex_exact() { - assert_eq!(template_to_path_regex("/users/me"), "^/users/me$"); - } - - #[test] - fn template_to_path_regex_single_param() { - assert_eq!( - template_to_path_regex("/files/{file_id}"), - "^/files/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_multi_param() { - assert_eq!( - template_to_path_regex("/automations/{exec_id}/nodes/{node_id}"), - "^/automations/[^/]+/nodes/[^/]+$" - ); - } - - #[test] - fn template_to_path_regex_escapes_dot() { - // e.g. /files/{file_id}/thumbnail.{extension} - let re = template_to_path_regex("/files/{file_id}/thumbnail.{extension}"); - assert_eq!(re, "^/files/[^/]+/thumbnail\\.[^/]+$"); - assert!(re.contains("\\."), "dot must be escaped so it only matches a literal dot"); - } -} diff --git a/seed/cli/x-fern-default/tests/lib_api.rs b/seed/cli/x-fern-default/tests/lib_api.rs deleted file mode 100644 index 88873a636993..000000000000 --- a/seed/cli/x-fern-default/tests/lib_api.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Tests for the public library API surface. -//! -//! These verify that customers can use the library as documented. - -#[test] -fn test_cli_app_builder_chain() { - let app = fern_cli_sdk::openapi::CliApp::new("test") - .spec(include_str!("../src/openapi/__fixtures__/openapi.json")) - .auth_scheme_env("bearer", "TEST_TOKEN") - .command( - clap::Command::new("custom").about("A custom command"), - |_args, _ctx| Ok(()), - ); - - // Builder chain completes without panic — the app is ready to run - // (We can't inspect private fields from integration tests, but the - // builder pattern itself is the test: if it compiles, the API works.) - drop(app); -} - -#[test] -fn test_building_blocks_accessible() { - // Verify all public modules are importable and types are usable - let spec = include_str!("../src/openapi/__fixtures__/openapi.json"); - let doc = fern_cli_sdk::openapi::load_openapi_spec(spec, "test").unwrap(); - let cmd = fern_cli_sdk::openapi::commands::build_cli(&doc); - - assert!(cmd.find_subcommand("users").is_some()); - assert!(cmd.find_subcommand("files").is_some()); - - // Verify key types are accessible - let _format = fern_cli_sdk::formatter::OutputFormat::Json; - let _pagination = fern_cli_sdk::openapi::executor::PaginationConfig::default(); -} - -#[test] -fn test_error_type_accessible() { - let err = fern_cli_sdk::error::CliError::Validation("test".to_string()); - assert_eq!(err.exit_code(), 3); -} diff --git a/seed/cli/x-fern-default/tests/openapi_streaming_wire.rs b/seed/cli/x-fern-default/tests/openapi_streaming_wire.rs deleted file mode 100644 index d009bdbf8ede..000000000000 --- a/seed/cli/x-fern-default/tests/openapi_streaming_wire.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tier-2 wire tests for `x-fern-streaming` (FER-9864). -//! -//! Each test: -//! 1. Authors a minimal OpenAPI spec inline that declares one streaming -//! operation under `x-fern-streaming` (either SSE or NDJSON). -//! 2. Stands up a fresh `wiremock::MockServer` that returns a hard-coded -//! streamed body — `\n`-joined frames the executor must split. -//! 3. Drives [`fern_cli_sdk::openapi::executor::execute_method`] against -//! the mock and asserts the request shape (path) and the events -//! captured into the buffered response value match expected ordering. -//! -//! The executor's *streaming* path (default — no `--no-stream`) writes -//! each event to stdout as it arrives, which is hard to capture from a -//! library test. The buffered branch (selected here via -//! `capture_output = true`) consumes the *same* `decode_stream_event` -//! pipeline and stores each event in order — so a regression in framing -//! or terminator handling fails this test before it reaches the CLI -//! surface. The CLI-binary end-to-end coverage of streaming output is -//! exercised in the smoke test under `tests/box_smoke.rs` follow-up. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("openapi-streaming-wire").unwrap() -} - -/// Tiny OpenAPI document with one operation under `/stream` whose -/// `x-fern-streaming` payload is parameterized. Returning the YAML -/// from a single helper keeps each test focused on the body the -/// mock returns. -fn streaming_spec(extension: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Streaming Wire - version: "1.0" -servers: - - url: PLACEHOLDER -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /stream: - post: - operationId: streamChat - x-fern-streaming: {extension} - responses: - "200": - description: stream -"# - ) -} - -/// Mount a single streaming mock. Wiremock's `set_body_string` -/// returns the entire body in one shot at the HTTP level — the -/// executor must still split it into discrete events using -/// `decode_stream_event`, which is the surface this test locks. -async fn mount_stream(server: &MockServer, body: &str) { - Mock::given(method("POST")) - .and(path("/stream")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_string(body.to_string())) - .expect(1) - .mount(server) - .await; -} - -/// Drive the streaming operation through the executor's *buffered* -/// branch (capture_output = true) so the test can assert against -/// the collected events. The executor still runs the full -/// `decode_stream_event` pipeline; only the final emit step differs -/// from the live `stream_response` path. -async fn drive_stream(spec: &str, server: &MockServer) -> serde_json::Value { - let spec = spec.replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → collect events into a Value - None, // base_url_override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream — irrelevant when capture_output is set - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - result.expect("streaming response must produce a value") -} - -#[tokio::test] -async fn streaming_sse_emits_events_in_order_and_honors_terminator() { - let server = MockServer::start().await; - // Mix `event:` framing and a comment line ahead of two real events, - // then the spec-declared `[DONE]` sentinel. The executor must skip - // the framing/comment lines and stop reading at the sentinel. - let body = "\ -: keepalive -event: message -data: {\"index\":0,\"delta\":\"hello\"} - -event: message -data: {\"index\":1,\"delta\":\"world\"} - -data: [DONE] - -data: {\"index\":2,\"delta\":\"AFTER\"} -"; - mount_stream(&server, body).await; - - // The terminator is part of the spec (no implicit default after - // dropping the `[DONE]` fallback to match TS/C# typed-SDK parity). - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[DONE]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2, "events after [DONE] must be dropped"); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[0]["delta"], "hello"); - assert_eq!(events[1]["index"], 1); - assert_eq!(events[1]["delta"], "world"); -} - -#[tokio::test] -async fn streaming_ndjson_emits_one_value_per_line() { - let server = MockServer::start().await; - let body = "\ -{\"id\":1,\"role\":\"user\"} -{\"id\":2,\"role\":\"assistant\"} -{\"id\":3,\"role\":\"assistant\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec("true"), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three NDJSON values should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["id"], 1); - assert_eq!(events[1]["id"], 2); - assert_eq!(events[2]["id"], 3); -} - -#[tokio::test] -async fn streaming_sse_custom_terminator_replaces_default_sentinel() { - let server = MockServer::start().await; - // Custom terminator `[END]`: the executor must stop here, and - // `[DONE]` (which used to be the implicit default before this - // change landed) is now a regular event payload. - let body = "\ -data: {\"step\":1} - -data: [DONE] - -data: {\"step\":2} - -data: [END] - -data: {\"step\":\"unreachable\"} -"; - mount_stream(&server, body).await; - - let value = drive_stream( - &streaming_spec(r#"{ format: sse, terminator: "[END]" }"#), - &server, - ) - .await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three pre-terminator events, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0]["step"], 1); - // `[DONE]` is now a regular event payload (string after stripping - // the `data:` prefix and one leading space). - assert_eq!(events[1].as_str(), Some("[DONE]")); - assert_eq!(events[2]["step"], 2); -} - -#[tokio::test] -async fn streaming_sse_concatenates_multiline_data_into_one_event() { - // A single event spanning three `data:` lines (e.g. a - // pretty-printed JSON payload) must join with `\n` and dispatch - // once on the blank-line separator — matches the WHATWG SSE - // spec and the TS runtime's `iterSseEvents` loop. Without this, - // Gemini-style multi-line streams would dispatch each line as - // its own corrupt JSON fragment. - let server = MockServer::start().await; - let body = "\ -data: { -data: \"foo\": 1 -data: } - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - // Single buffered event → unwraps to the joined JSON object. - assert_eq!(value["foo"], 1); -} - -#[tokio::test] -async fn streaming_sse_separates_events_on_blank_line() { - // Two distinct events separated by a blank line dispatch as two - // payloads. Each block accumulates its own `data:` lines. - let server = MockServer::start().await; - let body = "\ -data: {\"index\":0} - -data: {\"index\":1} - -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("two events should array-wrap, got {value:?}")); - assert_eq!(events.len(), 2); - assert_eq!(events[0]["index"], 0); - assert_eq!(events[1]["index"], 1); -} - -#[tokio::test] -async fn streaming_sse_flushes_final_event_without_trailing_blank_line() { - // Stream ends mid-event (no trailing blank line). The executor - // must still flush the buffered payload at EOF — mirrors the TS - // post-loop `if (dataValue != null)` dispatch. - let server = MockServer::start().await; - let body = "data: {\"final\":\"answer\"}"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - assert_eq!(value["final"], "answer"); -} - -#[tokio::test] -async fn streaming_text_emits_each_non_empty_line_as_string_event() { - let server = MockServer::start().await; - // Three real lines plus a blank separator. The executor must - // emit each non-empty line verbatim as a plain string event — - // no JSON parse, no SSE prefix strip, no terminator check - // (mirrors the C# generator at - // `HttpEndpointGenerator.ts:815-825`). - let body = "\ -first line of output - -second line of output -third line of output -"; - mount_stream(&server, body).await; - - let value = drive_stream(&streaming_spec(r#"{ format: text }"#), &server).await; - let events = value - .as_array() - .unwrap_or_else(|| panic!("three text lines should array-wrap, got {value:?}")); - assert_eq!(events.len(), 3); - assert_eq!(events[0].as_str(), Some("first line of output")); - assert_eq!(events[1].as_str(), Some("second line of output")); - assert_eq!(events[2].as_str(), Some("third line of output")); -} - -#[tokio::test] -async fn streaming_no_stream_flag_buffers_into_unary_value() { - // When `--no-stream` is set, the executor collapses the response - // into a single value. The buffered path is the same one - // `capture_output = true` uses; we exercise it here with - // `no_stream = true` and `capture_output = false` via the - // `--no-stream` plumbing on `execute_method` directly. - // - // The test asserts that a single-event body unwraps to that - // event's JSON value rather than a one-element array — the - // surface a JSON pipe (e.g. `… | jq`) expects. - let server = MockServer::start().await; - // No explicit terminator in the body — the executor must read - // until EOF when the spec doesn't declare a sentinel (matches the - // TS / C# typed-SDK runtimes). - let body = "data: {\"final\":\"answer\"}\n\n"; - mount_stream(&server, body).await; - - let spec = streaming_spec(r#"{ format: sse }"#).replace("PLACEHOLDER", &server.uri()); - let doc = load_openapi_spec(&spec, "openapi-streaming-wire").unwrap(); - let method = doc.resources["stream"].methods["stream-chat"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output — verify the buffered Value shape - None, - &default_http_config(), - false, // no_extract - false, // no_retry - true, // no_stream — irrelevant under capture_output but the flag - // must not flip behavior into an error - &[], // no x-fern-global-headers in this fixture - ) - .await - .expect("execute_method must succeed against the streaming mock"); - let value = result.expect("streaming response must produce a value"); - // Single event → unwrap to the event's JSON value, not a 1-array. - assert_eq!(value["final"], "answer"); -} - -/// Regression guard: the cli-sdk runtime must NOT inject a -/// streaming-specific `Accept` header. The TypeScript and C# typed -/// SDKs in `fern-api/fern` don't set one for SSE/NDJSON endpoints, -/// and cli-sdk's parity rule for FER-9864 work is to mirror the -/// typed SDKs' behavior. wiremock matchers can only assert headers -/// that *exist*, so we inspect the recorded request directly — same -/// pattern as `tests/auth_routing_wire.rs` uses for asserting -/// Authorization absence. -#[tokio::test] -async fn streaming_endpoints_do_not_inject_accept_header() { - let server = MockServer::start().await; - let body = "data: {\"ok\":true}\n\ndata: [DONE]\n"; - mount_stream(&server, body).await; - - let _ = drive_stream(&streaming_spec(r#"{ format: sse }"#), &server).await; - - let recorded = server - .received_requests() - .await - .expect("MockServer should record requests"); - assert_eq!(recorded.len(), 1, "exactly one streaming request expected"); - let accept_values: Vec = recorded[0] - .headers - .get_all("accept") - .iter() - .map(|v| v.to_str().unwrap_or_default().to_string()) - .collect(); - for value in &accept_values { - assert!( - !value.contains("text/event-stream"), - "regression: streaming endpoint injected SSE-specific Accept header: {value:?}" - ); - assert!( - !value.contains("x-ndjson") && !value.contains("jsonl"), - "regression: streaming endpoint injected NDJSON-specific Accept header: {value:?}" - ); - } -} diff --git a/seed/cli/x-fern-default/tests/tls_env_vars.rs b/seed/cli/x-fern-default/tests/tls_env_vars.rs deleted file mode 100644 index fe2167e347e2..000000000000 --- a/seed/cli/x-fern-default/tests/tls_env_vars.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! Integration test for the SDK's TLS env var contract. -//! -//! Verifies that `_CA_BUNDLE`, `_INSECURE`, `SSL_CERT_FILE`, -//! etc. actually change the TLS trust outcome of the HTTP client built -//! by [`fern_cli_sdk::http::HttpConfig::build_client`]. -//! -//! Approach: spin up a local HTTPS server with a brand-new self-signed cert -//! that is never trusted by the system, then exercise the client against it -//! under different env-var configurations. This isolates the test from -//! whatever's in the developer's keychain (live tests against real APIs -//! can't be trusted to verify env-var behavior in isolation). -//! -//! Requirements: `python3` and `openssl` on PATH (both standard on dev/CI -//! machines). The test will skip itself with a printed warning if either is -//! missing. - -use std::process::{Child, Command, Stdio}; -use std::time::Duration; - -use fern_cli_sdk::http::HttpConfig; - -const CLI_NAME: &str = "tls-test-cli"; -const ENV_PREFIX: &str = "TLS_TEST_CLI"; // CLI_NAME uppercased, `-` → `_` - -/// Server fixture: a self-signed HTTPS server on a random localhost port, -/// with paths to the cert and a different (unsigned) "bogus" cert for negative -/// tests. Drops the server process and tempdir on Drop. -struct Fixture { - port: u16, - cert_path: std::path::PathBuf, - bogus_cert_path: std::path::PathBuf, - _tmp: tempfile::TempDir, - _child: ChildGuard, -} - -struct ChildGuard(Child); -impl Drop for ChildGuard { - fn drop(&mut self) { - let _ = self.0.kill(); - let _ = self.0.wait(); - } -} - -fn deps_available() -> bool { - fn has(cmd: &str) -> bool { - Command::new(cmd) - .arg("--version") - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - } - has("python3") && has("openssl") -} - -fn unused_port() -> u16 { - // Bind to :0, ask the kernel for a port, then immediately release it. - // There's a tiny race window before the test server binds, but in - // practice it's fine for an integration test. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); - listener.local_addr().expect("local_addr").port() -} - -fn make_fixture() -> Fixture { - let tmp = tempfile::tempdir().expect("tmpdir"); - let p = |name: &str| tmp.path().join(name).to_str().unwrap().to_string(); - - // We generate a proper CA → leaf chain rather than a single self-signed - // CA-as-leaf cert. rustls (correctly) rejects the latter with - // `CaUsedAsEndEntity`; native-tls / Secure Transport tolerates it. The - // proper structure is what real-world fixtures (e.g. Proxyman) produce. - - // 1. Trust root (the "CA"). This is what we'll point _CA_BUNDLE at. - let ca_pem = p("ca.pem"); - let ca_key = p("ca.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=test-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &ca_key, - "-out", &ca_pem, - ]); - - // 2. Leaf cert for the test server, signed by the CA above. - let leaf_pem = p("leaf.pem"); - let leaf_key = p("leaf.key"); - let leaf_csr = p("leaf.csr"); - let leaf_ext = p("leaf.ext"); - std::fs::write( - &leaf_ext, - "subjectAltName=IP:127.0.0.1\nextendedKeyUsage=serverAuth\n", - ) - .unwrap(); - run_openssl(&[ - "req", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=127.0.0.1", - "-keyout", &leaf_key, - "-out", &leaf_csr, - ]); - run_openssl(&[ - "x509", "-req", "-in", &leaf_csr, - "-CA", &ca_pem, "-CAkey", &ca_key, "-CAcreateserial", - "-out", &leaf_pem, - "-days", "1", - "-extfile", &leaf_ext, - ]); - - // 3. Bogus CA — a different self-signed CA whose private key never signs - // anything we'll encounter. Loading this in _CA_BUNDLE must NOT make - // the leaf trusted (proves the bundle isn't a "trust everything" knob). - let bogus_pem = p("bogus.pem"); - let bogus_key = p("bogus.key"); - run_openssl(&[ - "req", "-x509", "-newkey", "rsa:2048", "-nodes", - "-subj", "/CN=bogus-ca", - "-addext", "basicConstraints=critical,CA:TRUE", - "-addext", "keyUsage=critical,keyCertSign,cRLSign", - "-days", "1", - "-keyout", &bogus_key, - "-out", &bogus_pem, - ]); - - let port = unused_port(); - - // The Python server needs the leaf cert + leaf key. Cert/key paths and - // port are passed as argv to avoid mixing Rust's format! braces with - // Python's literal dict braces. - let server_script = r#" -import http.server, json, ssl, sys -cert, key, port = sys.argv[1], sys.argv[2], int(sys.argv[3]) -class H(http.server.BaseHTTPRequestHandler): - def do_GET(self): - body = json.dumps({"ok": True}).encode() - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - def log_message(self, *a, **kw): - pass -ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) -ctx.load_cert_chain(certfile=cert, keyfile=key) -srv = http.server.HTTPServer(("127.0.0.1", port), H) -srv.socket = ctx.wrap_socket(srv.socket, server_side=True) -srv.serve_forever() -"#; - - let child = Command::new("python3") - .arg("-c") - .arg(server_script) - .arg(&leaf_pem) - .arg(&leaf_key) - .arg(port.to_string()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("python3 spawn"); - - // Give the server a moment to bind before the first request. - std::thread::sleep(Duration::from_millis(400)); - - Fixture { - port, - cert_path: ca_pem.into(), - bogus_cert_path: bogus_pem.into(), - _tmp: tmp, - _child: ChildGuard(child), - } -} - -/// Run `openssl ` and panic with stderr + the failing arg list if it -/// exits non-zero. Capturing stderr makes test failures self-explanatory -/// instead of "openssl exited with code 1, good luck." -fn run_openssl(args: &[&str]) { - let output = Command::new("openssl") - .args(args) - .output() - .unwrap_or_else(|e| panic!("failed to spawn openssl ({args:?}): {e}")); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - panic!( - "openssl failed (exit={:?}) for args {args:?}\nstderr:\n{stderr}", - output.status.code() - ); - } -} - -/// Wipe every env var that could leak into the test from the developer's -/// shell (Proxyman's auto-setup sets several of these). Must run *before* -/// HttpConfig::build_client() reads the environment. -fn clean_env() { - for k in [ - "SSL_CERT_FILE", - "SSL_CERT_DIR", - "HTTPS_PROXY", - "HTTP_PROXY", - "https_proxy", - "http_proxy", - "NODE_EXTRA_CA_CERTS", - "CURL_CA_BUNDLE", - "REQUESTS_CA_BUNDLE", - "TLS_TEST_CLI_CA_BUNDLE", - "TLS_TEST_CLI_EXTRA_CA_CERTS", - "TLS_TEST_CLI_INSECURE", - "TLS_TEST_CLI_INSECURE_SKIP_VERIFY", - "TLS_TEST_CLI_PROXY", - "TLS_TEST_CLI_NO_PROXY", - ] { - std::env::remove_var(k); - } -} - -async fn fetch(client: &reqwest::Client, port: u16) -> Result { - Ok(client - .get(format!("https://127.0.0.1:{port}/probe")) - .send() - .await? - .status()) -} - -/// Build a fresh client from the current env. Each test case mutates env -/// and then constructs a client to capture the new state — every test calls -/// this exactly once. -fn build_client() -> reqwest::Client { - try_build_client().expect("client build") -} - -/// Like [`build_client`] but doesn't unwrap the build error — useful for -/// cases that expect a malformed env var to surface as an error at -/// construction. -fn try_build_client() -> Result { - HttpConfig::new(CLI_NAME).unwrap().build_client() -} - -/// Cases run sequentially in a single test. Reqwest constructs new clients -/// fresh from the env each call, so we just mutate env between cases and -/// verify each. -/// -/// We use `serial_test::serial` so the env mutations don't race with other -/// tests in the binary. -#[tokio::test] -#[serial_test::serial] -async fn tls_env_vars_change_trust_outcome() { - if !deps_available() { - eprintln!("SKIP: tls_env_vars test needs python3 + openssl on PATH"); - return; - } - - let fx = make_fixture(); - let port = fx.port; - let cert = fx.cert_path.to_str().unwrap().to_string(); - let bogus = fx.bogus_cert_path.to_str().unwrap().to_string(); - - // ---- A: no env vars → must fail --------------------------------------- - clean_env(); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("A: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "A: expected TLS / connect error, got: {err}" - ); - - // ---- B: _CA_BUNDLE → must succeed ----------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("B: must succeed"); - assert_eq!(status.as_u16(), 200, "B: expected 200"); - - // ---- C: _INSECURE=1 → must succeed ---------------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE"), "1"); - let client = build_client(); - let status = fetch(&client, port).await.expect("C: must succeed"); - assert_eq!(status.as_u16(), 200, "C: expected 200"); - - // ---- D: bogus _CA_BUNDLE → must fail ---------------------------------- - // Confirms the bundle isn't accidentally treated as "trust everything". - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), &bogus); - let client = build_client(); - let err = fetch(&client, port).await.expect_err("D: must fail TLS"); - assert!( - err.is_connect() || err.to_string().to_lowercase().contains("certificate"), - "D: expected TLS error, got: {err}" - ); - - // ---- E: SSL_CERT_FILE fallback → must succeed ------------------------- - clean_env(); - std::env::set_var("SSL_CERT_FILE", &cert); - let client = build_client(); - let status = fetch(&client, port).await.expect("E: must succeed"); - assert_eq!(status.as_u16(), 200, "E: expected 200 via SSL_CERT_FILE"); - - // ---- F: alias _INSECURE_SKIP_VERIFY → must succeed -------------------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_INSECURE_SKIP_VERIFY"), "true"); - let client = build_client(); - let status = fetch(&client, port).await.expect("F: must succeed"); - assert_eq!(status.as_u16(), 200, "F: expected 200 via alias"); - - // ---- G: missing _CA_BUNDLE path → must error at client build --------- - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_CA_BUNDLE"), "/no/such/path.pem"); - let err = try_build_client().expect_err("G: must error"); - let msg = err.to_string(); - assert!( - msg.contains("/no/such/path.pem"), - "G: error should name the bad path; got: {msg}" - ); - - // ---- H: _NO_PROXY must NOT mutate global NO_PROXY ------------- - // Earlier the implementation called std::env::set_var("NO_PROXY", ...) - // as a side effect, leaking config to other code paths. Verify it doesn't. - clean_env(); - let original_no_proxy = std::env::var("NO_PROXY").ok(); - std::env::set_var(format!("{ENV_PREFIX}_NO_PROXY"), "internal.example.com"); - let _ = build_client(); - let after_no_proxy = std::env::var("NO_PROXY").ok(); - assert_eq!( - original_no_proxy, after_no_proxy, - "H: _NO_PROXY leaked into global NO_PROXY" - ); - - // ---- I: invalid _PROXY URL → must error at client build ------ - clean_env(); - std::env::set_var(format!("{ENV_PREFIX}_PROXY"), "not a url"); - let err = try_build_client().expect_err("I: must error"); - let msg = err.to_string(); - assert!( - msg.contains(&format!("{ENV_PREFIX}_PROXY")), - "I: error should name the env var; got: {msg}" - ); - - clean_env(); -} diff --git a/seed/cli/x-fern-default/tests/websocket_wire.rs b/seed/cli/x-fern-default/tests/websocket_wire.rs deleted file mode 100644 index c49bde672f80..000000000000 --- a/seed/cli/x-fern-default/tests/websocket_wire.rs +++ /dev/null @@ -1,900 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// -// Integration tests for `fern_cli_sdk::websocket`. -// -// Each test spawns an in-process WS server on `127.0.0.1:0` (ephemeral -// port), drives a `WebSocketClient` against it, and asserts on the -// mock's view of what the client did + on the client's return value. -// -// Tests deliberately avoid asserting on stdout content. The transforms -// applied to each frame before emit (autoresponder elision, audio-key -// stripping, JSON parsing) are unit-tested in `src/websocket/client.rs`; -// the wire tests cover the loop wiring and the failure-mode matrix. - -use std::time::Duration; - -use futures_util::{SinkExt, StreamExt}; -use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::{self, Message}; - -use fern_cli_sdk::auth::AuthCredentialSource; -use fern_cli_sdk::error::CliError; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::websocket::{AutoResponder, WebSocketClient, WsAuth, WsConfig}; - -/// Test-local ping/pong autoresponder. -/// Matches `{"type":"ping","ping_event":{"event_id":}}` and replies -/// with `{"type":"pong","event_id":}`. -fn test_ping_pong_responder() -> AutoResponder { - std::sync::Arc::new(|frame: &Value| -> Option { - if frame.get("type").and_then(|v| v.as_str()) != Some("ping") { - return None; - } - frame - .pointer("/ping_event/event_id") - .and_then(|v| v.as_i64()) - .map(|event_id| json!({"type": "pong", "event_id": event_id})) - }) -} - -// ----------------------------------------------------------------------------- -// Mock-server helpers -// ----------------------------------------------------------------------------- - -/// Bind a TCP listener on `127.0.0.1:0`. Returns the bound port so tests -/// can build the `ws://127.0.0.1:/` URL without racing on a -/// hardcoded port. -async fn bind_ephemeral() -> (TcpListener, u16) { - let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); - let port = listener.local_addr().expect("addr").port(); - (listener, port) -} - -/// Accept one upgrade and hand the connected server-side stream to -/// `handler`. Returns the handler's join handle so the test can await -/// the server-side side of the conversation. -fn spawn_one_shot_ws( - listener: TcpListener, - handler: F, -) -> tokio::task::JoinHandle<()> -where - F: FnOnce( - tokio_tungstenite::WebSocketStream, - ) -> Fut - + Send - + 'static, - Fut: std::future::Future + Send + 'static, -{ - tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) - .await - .expect("ws handshake"); - handler(ws).await; - }) -} - -/// Standard HttpConfig for tests (no env-var overrides honored anyway). -fn test_http_config() -> HttpConfig { - HttpConfig::new("ws-wire-test").unwrap() -} - -// ----------------------------------------------------------------------------- -// 1. Handshake succeeds against a vanilla accept_async. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_succeeds() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Server side: send a normal Close(1000) immediately so the - // client returns Ok. Reading the eventual client-side Close - // keeps both sides in lockstep. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - // Drain anything the client sends after seeing the close - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .expect("handshake should succeed"); - - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = shutdown_rx.await; - }); - let result = client.run_until_shutdown(shutdown).await; - drop(shutdown_tx); - server.await.ok(); - - // Server-side normal close → Ok per matrix. - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 2. Three inbound frames flow through the client without error. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn round_trips_three_frames() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - for i in 1..=3 { - ws.send(Message::Text(json!({"n": i}).to_string())) - .await - .ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 3. Server-initiated Close(1000) mid-stream → Ok(()). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1000_mid_stream_exits_zero() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Text(json!({"hi": true}).to_string())) - .await - .ok(); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "done".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok(()), got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 4. Server-initiated abnormal close → CliError::Other with the hint. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_abnormal_maps_to_other_with_hint() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // CloseCode::Error is the named variant for 1011 (Internal Error). - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "server error".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should be an error"), - }; - assert!(matches!(err, CliError::Other(_))); - let msg = err.to_string(); - assert!(msg.contains("1011"), "missing close code: {msg}"); - // Default `WsConfig::new` is API-neutral; per-API constructors weave - // their own hint (covered by `custom_abnormal_close_hint_appears_in_error`). - assert!( - msg.contains("keepalive") || msg.contains("auth"), - "default hint should mention auth or keepalive: {msg}", - ); - // Exit code per matrix: Other = 5. - assert_eq!(err.exit_code(), 5); -} - -// ----------------------------------------------------------------------------- -// 5. Shutdown future fires mid-stream → client sends Close(1000), exits Ok. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn shutdown_future_sends_close_and_exits_zero() { - let (listener, port) = bind_ephemeral().await; - // Channel from server back to test, to confirm the close frame arrived. - let (close_tx, close_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - let mut close_seen: Option = None; - // Just listen; the test triggers shutdown on the client side. - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Close(frame)) => { - close_seen = frame.as_ref().map(|f| u16::from(f.code)); - break; - } - Ok(_) => continue, - Err(_) => break, - } - } - close_tx.send(close_seen.unwrap_or(0)).ok(); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - - let (trigger_tx, trigger_rx) = tokio::sync::oneshot::channel::<()>(); - let shutdown = Box::pin(async move { - let _ = trigger_rx.await; - }); - let client_task = tokio::spawn(client.run_until_shutdown(shutdown)); - - // Give the connection a moment to establish, then trigger. - tokio::time::sleep(Duration::from_millis(50)).await; - trigger_tx.send(()).unwrap(); - - let result = client_task.await.expect("join"); - server.await.ok(); - - assert!(matches!(result, Ok(())), "expected Ok, got: {result:?}"); - let code = tokio::time::timeout(Duration::from_secs(2), close_rx) - .await - .expect("close-frame channel timeout") - .expect("close-frame channel closed"); - assert_eq!(code, 1000, "client should send Normal Closure on shutdown"); -} - -// ----------------------------------------------------------------------------- -// 6. Bad URL → CliError::Validation, exit 3. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn bad_url_maps_to_validation_error() { - let cfg = WsConfig::new("not a url"); - // `WebSocketClient` doesn't implement Debug (it holds a stream that - // doesn't), so use match instead of expect_err. - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("invalid URL should error"), - }; - assert!(matches!(err, CliError::Validation(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 3); -} - -// ----------------------------------------------------------------------------- -// 7. Autoresponder elides ping + sends matching pong. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_elides_ping_and_sends_pong() { - let (listener, port) = bind_ephemeral().await; - let (pong_tx, pong_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Send an app-level ping frame. - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 42, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - - // Wait for the pong. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).ok(); - } - - // Clean close. - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let pong = tokio::time::timeout(Duration::from_secs(2), pong_rx) - .await - .expect("pong-channel timeout") - .expect("pong-channel closed"); - assert_eq!(pong, json!({"type": "pong", "event_id": 42})); -} - -// ----------------------------------------------------------------------------- -// 8. First-message auth: WsAuth::FirstMessage merges field into first send. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_message_auth_field_injected() { - let (listener, port) = bind_ephemeral().await; - let (first_msg_tx, first_msg_rx) = tokio::sync::oneshot::channel::(); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - if let Some(Ok(Message::Text(text))) = ws.next().await { - let v: Value = serde_json::from_str(&text).unwrap(); - first_msg_tx.send(v).ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-test-merged"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client - .send(&json!({"text": "hello", "voice_settings": {"stability": 0.5}})) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok()); - let first = tokio::time::timeout(Duration::from_secs(2), first_msg_rx) - .await - .expect("first-msg timeout") - .expect("first-msg channel closed"); - assert_eq!(first["xi_api_key"], "sk-test-merged"); - assert_eq!(first["text"], "hello"); - assert_eq!(first["voice_settings"]["stability"], 0.5); -} - -// ----------------------------------------------------------------------------- -// 9. Header auth: WsAuth::Header puts the value on the handshake. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn header_auth_sent_on_handshake() { - let (listener, port) = bind_ephemeral().await; - let (hdr_tx, hdr_rx) = tokio::sync::oneshot::channel::>(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(None)); - let captured_clone = captured.clone(); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - if let Some(v) = req.headers().get("xi-api-key") { - *captured_clone.lock().unwrap() = - Some(v.to_str().unwrap_or("").to_string()); - } - Ok(resp) - }; - let ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - // Send a clean close so the client returns Ok. - let mut ws = ws; - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - let final_val = captured.lock().unwrap().clone(); - hdr_tx.send(final_val).ok(); - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Header( - "xi-api-key".into(), - AuthCredentialSource::literal("sk-header-test"), - ); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = tokio::time::timeout(Duration::from_secs(2), hdr_rx) - .await - .expect("header-channel timeout") - .expect("header-channel closed"); - assert_eq!(observed.as_deref(), Some("sk-header-test")); -} - -// ----------------------------------------------------------------------------- -// 10. Multi-frame conversation: ping/text/ping/text/close. Asserts the -// autoresponder elides only the ping frames, the client emits the -// other frames, and pongs come back with matching event_ids. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn auto_responder_interleaved_with_data_frames() { - let (listener, port) = bind_ephemeral().await; - // Collect every pong from the client. We expect exactly two, with - // event_ids 100 and 200 in order. - let (pong_tx, mut pong_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - // Frame 1: ping (should be auto-handled, NOT emitted). - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 100, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - // Frame 2: data (should flow to OutputPipeline::emit). - ws.send(Message::Text( - json!({"type": "agent_response", "text": "hello world"}).to_string(), - )) - .await - .ok(); - // Wait for first pong, then send second ping. - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Text( - json!({"type": "ping", "ping_event": {"event_id": 200, "ping_ms": 50}}) - .to_string(), - )) - .await - .ok(); - if let Some(Ok(Message::Text(reply))) = ws.next().await { - let v: Value = serde_json::from_str(&reply).unwrap(); - pong_tx.send(v).await.ok(); - } - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auto_responder = Some(test_ping_pong_responder()); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - assert!(result.is_ok(), "expected Ok, got: {result:?}"); - let first = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("first pong timeout") - .expect("first pong channel closed"); - let second = tokio::time::timeout(Duration::from_secs(2), pong_rx.recv()) - .await - .expect("second pong timeout") - .expect("second pong channel closed"); - assert_eq!(first, json!({"type": "pong", "event_id": 100})); - assert_eq!(second, json!({"type": "pong", "event_id": 200})); -} - -// ----------------------------------------------------------------------------- -// Raw-TCP helper for handshake-status tests: read the HTTP upgrade request -// (until we see the blank-line terminator) and write a fixed HTTP response. -// Lets us simulate 401 / 404 / 503 / etc. on the upgrade without involving -// `accept_async` (which would force a real WS handshake). -// ----------------------------------------------------------------------------- - -async fn answer_with_http_status( - listener: TcpListener, - status_line: &'static str, - body: &'static str, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let (mut stream, _) = listener.accept().await.expect("accept"); - // Read until we see the blank line that terminates the request headers. - let mut buf = Vec::with_capacity(1024); - let mut chunk = [0u8; 256]; - loop { - match stream.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; - } - } - Err(_) => break, - } - } - let response = format!( - "{status_line}\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n{body}", - len = body.len(), - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - }) -} - -// ----------------------------------------------------------------------------- -// 11. Handshake 401 → CliError::Auth (exit 2). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_401_maps_to_auth_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 401 Unauthorized", - "missing api key", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("401 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Auth(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 2); -} - -// ----------------------------------------------------------------------------- -// 12. Handshake 404 → CliError::Discovery (exit 4). -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_404_maps_to_discovery_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 404 Not Found", - "no such endpoint", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("404 upgrade should fail handshake"), - }; - server.await.ok(); - assert!(matches!(err, CliError::Discovery(_)), "got: {err:?}"); - assert_eq!(err.exit_code(), 4); -} - -// ----------------------------------------------------------------------------- -// 13. Handshake 503 → CliError::Api (exit 1) with status code captured. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn handshake_5xx_maps_to_api_error() { - let (listener, port) = bind_ephemeral().await; - let server = answer_with_http_status( - listener, - "HTTP/1.1 503 Service Unavailable", - "upstream down", - ) - .await; - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let err = match WebSocketClient::connect(cfg, &test_http_config()).await { - Err(e) => e, - Ok(_) => panic!("503 upgrade should fail handshake"), - }; - server.await.ok(); - match err { - CliError::Api { code, .. } => { - assert_eq!(code, 503); - } - other => panic!("expected Api, got: {other:?}"), - } -} - -// ----------------------------------------------------------------------------- -// 14. Two-header auth: e.g. Authorization + an API-version header. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn two_header_auth_emits_both_pairs() { - let (listener, port) = bind_ephemeral().await; - let captured: std::sync::Arc>> = - std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let captured_clone = captured.clone(); - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let cb = move |req: &tungstenite::handshake::server::Request, - resp: tungstenite::handshake::server::Response| { - for header in &["Authorization", "X-Api-Version"] { - if let Some(v) = req.headers().get(*header) { - captured_clone.lock().unwrap().push(( - (*header).to_string(), - v.to_str().unwrap_or("").to_string(), - )); - } - } - Ok(resp) - }; - let mut ws = tokio_tungstenite::accept_hdr_async(stream, cb) - .await - .expect("ws handshake"); - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Normal, - reason: "".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::Headers(vec![ - ( - "Authorization".into(), - AuthCredentialSource::literal("Bearer sk-test"), - ), - ( - "X-Api-Version".into(), - AuthCredentialSource::literal("v1"), - ), - ]); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let observed = captured.lock().unwrap().clone(); - assert_eq!(observed.len(), 2, "expected both headers, got: {observed:?}"); - assert!(observed.contains(&("Authorization".to_string(), "Bearer sk-test".to_string()))); - assert!(observed.contains(&("X-Api-Version".to_string(), "v1".to_string()))); -} - -// ----------------------------------------------------------------------------- -// 15. Close(1001) Going Away (e.g. server session-cap expiry) → Ok(()), exit 0. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn close_1001_going_away_is_clean_exit() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Away, - reason: "session cap exceeded".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - assert!(matches!(result, Ok(())), - "1001 Going Away should be a clean exit, got: {result:?}"); -} - -// ----------------------------------------------------------------------------- -// 16. send_binary: client emits Message::Binary frames (e.g. PCM audio -// streaming). Mock asserts the bytes round-trip intact. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn send_binary_emits_binary_frames() { - let (listener, port) = bind_ephemeral().await; - let (rx_tx, mut rx_rx) = tokio::sync::mpsc::channel::>(4); - let server = spawn_one_shot_ws(listener, move |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Binary(bytes)) => { - if rx_tx.send(bytes).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - // 16-bit PCM frames are typical; mimic with a small payload. - client.send_binary(vec![0u8, 1, 2, 3, 0xFF, 0xFE]).await.unwrap(); - client.send_binary(vec![10, 20, 30]).await.unwrap(); - let shutdown = Box::pin(async { - // Give the server time to drain. - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let frame1 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("first binary frame timeout") - .expect("rx closed"); - let frame2 = tokio::time::timeout(Duration::from_secs(2), rx_rx.recv()) - .await - .expect("second binary frame timeout") - .expect("rx closed"); - assert_eq!(frame1, vec![0u8, 1, 2, 3, 0xFF, 0xFE]); - assert_eq!(frame2, vec![10u8, 20, 30]); -} - -// ----------------------------------------------------------------------------- -// 17. Custom abnormal_close_hint overrides the default in error messages. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn custom_abnormal_close_hint_appears_in_error() { - let (listener, port) = bind_ephemeral().await; - let server = spawn_one_shot_ws(listener, |mut ws| async move { - ws.send(Message::Close(Some(tungstenite::protocol::CloseFrame { - code: tungstenite::protocol::frame::coding::CloseCode::Error, - reason: "internal".into(), - }))) - .await - .ok(); - while ws.next().await.is_some() {} - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.abnormal_close_hint = - "custom hint: KeepAlive cadence + encoding".to_string(); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = match result { - Err(e) => e, - Ok(_) => panic!("abnormal close should error"), - }; - let msg = err.to_string(); - assert!(msg.contains("custom hint"), "missing custom hint: {msg}"); - assert!(!msg.contains("ping/pong"), - "default hint should NOT appear: {msg}"); -} - -// ----------------------------------------------------------------------------- -// 18. Regression: if a caller invokes `client.send(&...)` before -// `run_until_shutdown`, the `first_send_done` flag must propagate -// into the loop so the loop doesn't re-merge or double-process -// FirstMessage auth. Pre-fix bug: `first_send_done` was destructured -// away on entry to the loop. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn first_send_done_propagates_into_recv_loop() { - let (listener, port) = bind_ephemeral().await; - let (frames_tx, mut frames_rx) = tokio::sync::mpsc::channel::(4); - let server = spawn_one_shot_ws(listener, |mut ws| async move { - while let Some(msg) = ws.next().await { - match msg { - Ok(Message::Text(s)) => { - let v: Value = serde_json::from_str(&s).unwrap(); - if frames_tx.send(v).await.is_err() { - break; - } - } - Ok(Message::Close(_)) | Err(_) => break, - _ => {} - } - } - }); - - let mut cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - cfg.auth = WsAuth::FirstMessage( - "xi_api_key".into(), - AuthCredentialSource::literal("sk-once"), - ); - let mut client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - client.send(&json!({"text": "first"})).await.unwrap(); - let shutdown = Box::pin(async { - tokio::time::sleep(Duration::from_millis(100)).await; - }); - let _ = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let first = tokio::time::timeout(Duration::from_secs(2), frames_rx.recv()) - .await - .expect("first frame timeout") - .expect("rx closed"); - assert_eq!(first["xi_api_key"], "sk-once"); - assert_eq!(first["text"], "first"); - // No additional *text* frames should appear — the loop must not - // produce a second "first" send after the destructuring. The - // channel closes when the server task ends (after seeing the - // Close frame the client sends on graceful shutdown), so a `None` - // recv is also fine; only `Some(value)` would mean the loop - // synthesised an unexpected text frame. - match tokio::time::timeout(Duration::from_millis(200), frames_rx.recv()).await { - Err(_) => {} // timeout: no extra frame within the window. - Ok(None) => {} // channel closed by server (Close ack path). - Ok(Some(extra)) => { - panic!("loop synthesised an unexpected extra frame: {extra}"); - } - } -} - -// ----------------------------------------------------------------------------- -// 19. Stream ending without a close frame → CliError::Other. -// ----------------------------------------------------------------------------- - -#[tokio::test] -async fn abrupt_disconnect_maps_to_other_error() { - let (listener, port) = bind_ephemeral().await; - let server = tokio::spawn(async move { - let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream).await.expect("ws"); - // Drop the WS without sending a close frame. tungstenite will - // surface this as an abnormal close to the client. - drop(ws); - }); - - let cfg = WsConfig::new(format!("ws://127.0.0.1:{port}/")); - let client = WebSocketClient::connect(cfg, &test_http_config()) - .await - .unwrap(); - let shutdown = Box::pin(std::future::pending::<()>()); - let result = client.run_until_shutdown(shutdown).await; - server.await.ok(); - - let err = result.expect_err("abrupt drop should error"); - assert!(matches!(err, CliError::Other(_))); - assert_eq!(err.exit_code(), 5); -} diff --git a/seed/cli/x-fern-default/tests/x_name_server_alias_wire.rs b/seed/cli/x-fern-default/tests/x_name_server_alias_wire.rs deleted file mode 100644 index 1f97c3c8a600..000000000000 --- a/seed/cli/x-fern-default/tests/x_name_server_alias_wire.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Wire test for the legacy v1 server-name alias `x-name`. -//! -//! Confirms that an OpenAPI spec using the legacy spelling alone (no -//! `x-fern-server-name` anywhere) parses end-to-end and the resulting -//! command tree dispatches a real request through the executor against -//! a wiremock server. Mirrors fern's behavior in -//! `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`, -//! where `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` accepts either -//! key with v1-wins precedence on the rare spec that carries both. -//! -//! Pairs with the in-source unit tests covering the four shape -//! permutations (only v2 / only v1 / both / neither); this file pins -//! the end-to-end command-tree path so a future regression in the -//! parser → discovery → executor chain that drops legacy specs surfaces -//! as a wire failure rather than a silent miss. - -use std::sync::Arc; - -use fern_cli_sdk::auth::{AuthCredentialSource, BearerAuthProvider, DynAuthProvider}; -use fern_cli_sdk::formatter::OutputPipeline; -use fern_cli_sdk::http::HttpConfig; -use fern_cli_sdk::openapi::executor::{self, PaginationConfig}; -use fern_cli_sdk::openapi::load_openapi_spec; -use serde_json::json; -use wiremock::matchers::{header_regex, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -const TOKEN: &str = "x-name-wire-token"; - -fn bearer_provider() -> DynAuthProvider { - Arc::new(BearerAuthProvider::new( - "bearerAuth", - AuthCredentialSource::literal(TOKEN), - )) -} - -fn default_pagination() -> PaginationConfig { - PaginationConfig::default() -} - -fn default_http_config() -> HttpConfig { - HttpConfig::new("x-name-server-alias-wire").unwrap() -} - -/// Spec carrying only the legacy v1 alias `x-name`. No -/// `x-fern-server-name` anywhere — exercises the fallback read. -fn legacy_alias_spec(server_url: &str) -> String { - format!( - r#" -openapi: "3.0.0" -info: - title: Legacy Alias Wire - version: "1.0" -servers: - - url: {server_url} - x-name: LegacyProd - description: Legacy v1-named production server. -components: - securitySchemes: - bearerAuth: - type: http - scheme: bearer -security: - - bearerAuth: [] -paths: - /things: - get: - x-fern-sdk-group-name: ["things"] - x-fern-sdk-method-name: list - responses: - "200": - description: ok -"# - ) -} - -#[tokio::test] -async fn x_name_legacy_alias_drives_full_command_tree_dispatch() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/things")) - .and(header_regex( - "Authorization", - format!("^Bearer {TOKEN}$").as_str(), - )) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "things": [{"id": "thing-1"}], - }))) - .expect(1) - .mount(&server) - .await; - - let doc = load_openapi_spec(&legacy_alias_spec(&server.uri()), "x-name-wire").unwrap(); - - // Pre-flight: the parser surfaced the legacy spelling as a resolved - // server name (mirroring fern's importer) and exposes it via the - // `named_servers` helper that drives the help surface. - assert_eq!(doc.servers.len(), 1); - assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); - assert_eq!( - doc.servers[0].description.as_deref(), - Some("Legacy v1-named production server."), - ); - let named: Vec<_> = doc.named_servers().collect(); - assert_eq!(named.len(), 1); - assert_eq!(named[0].0, "LegacyProd"); - - // End-to-end wire: the executor dispatches against the spec's - // server URL and the mock observes exactly one matching request. - // If the parser had ignored `x-name`, the named-server data would - // still be empty here — but the operation still dispatches against - // the spec's `servers:` block, so the wire mock would still match. - // The pre-flight assertions above are what lock the legacy alias. - let method = doc.resources["things"].methods["list"].clone(); - let result = executor::execute_method( - &doc, - &method, - None, - None, - &bearer_provider(), - None, - None, - None, - false, - &default_pagination(), - &OutputPipeline::default(), - true, // capture_output → return the response body - None, // no base-url override - &default_http_config(), - false, // no_extract - false, // no_retry - false, // no_stream - &[], // no x-fern-global-headers - ) - .await - .expect("execute_method must succeed against the wire mock"); - - let body = result.expect("response body must be captured"); - assert_eq!(body["things"][0]["id"].as_str(), Some("thing-1")); -}