From de37d77a2b782ed3d8c31b7f5d59a29ceef72fa2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 18 Aug 2026 01:43:26 +0300 Subject: [PATCH 1/6] Replace the template with the unified hosting API and a Vercel provider Co-authored-by: Medulla --- .env.example | 20 +- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/workflows/release.yml | 2 +- AGENTS.md | 71 +- Cargo.lock | 1114 +++++++++++++++- Cargo.toml | 47 +- MODULE.md | 29 +- README.md | 211 +-- ROADMAP.md | 34 +- docs/README.md | 6 +- .../0002-a-provider-agnostic-hosting-model.md | 59 + docs/plans/README.md | 2 +- docs/plans/example-retry-policy.md | 71 -- docs/plans/tinybus-module-release.md | 4 +- docs/specs/README.md | 2 +- docs/specs/example-retry-policy.md | 63 - docs/specs/tinybus-module-release.md | 8 +- docs/specs/unified-hosting-api.md | 179 +++ examples/basic.rs | 53 +- examples/verify_github_release.rs | 19 +- examples/verify_module.rs | 15 +- src/bundle/mod.rs | 279 ++++ src/bundle/test.rs | 207 +++ src/credentials/mod.rs | 125 ++ src/credentials/test.rs | 76 ++ src/error/mod.rs | 174 ++- src/error/test.rs | 45 +- src/greeting/mod.rs | 38 - src/greeting/test.rs | 28 - src/host/mod.rs | 178 +++ src/host/test.rs | 249 ++++ src/host/types.rs | 612 +++++++++ src/launch/mod.rs | 96 ++ src/launch/test.rs | 321 +++++ src/launch/types.rs | 149 +++ src/lib.rs | 93 +- src/providers/mod.rs | 185 +++ src/providers/test.rs | 138 ++ src/providers/vercel/http.rs | 264 ++++ src/providers/vercel/mod.rs | 556 ++++++++ src/providers/vercel/test.rs | 1134 +++++++++++++++++ src/providers/vercel/wire.rs | 424 ++++++ src/rpc/mod.rs | 276 ++++ src/rpc/test.rs | 440 +++++++ src/tinybus_module/README.md | 28 +- src/tinybus_module/mod.rs | 43 +- src/tinybus_module/test.rs | 90 +- tests/public_api.rs | 112 +- 48 files changed, 7895 insertions(+), 476 deletions(-) create mode 100644 docs/adr/0002-a-provider-agnostic-hosting-model.md delete mode 100644 docs/plans/example-retry-policy.md delete mode 100644 docs/specs/example-retry-policy.md create mode 100644 docs/specs/unified-hosting-api.md create mode 100644 src/bundle/mod.rs create mode 100644 src/bundle/test.rs create mode 100644 src/credentials/mod.rs create mode 100644 src/credentials/test.rs delete mode 100644 src/greeting/mod.rs delete mode 100644 src/greeting/test.rs create mode 100644 src/host/mod.rs create mode 100644 src/host/test.rs create mode 100644 src/host/types.rs create mode 100644 src/launch/mod.rs create mode 100644 src/launch/test.rs create mode 100644 src/launch/types.rs create mode 100644 src/providers/mod.rs create mode 100644 src/providers/test.rs create mode 100644 src/providers/vercel/http.rs create mode 100644 src/providers/vercel/mod.rs create mode 100644 src/providers/vercel/test.rs create mode 100644 src/providers/vercel/wire.rs create mode 100644 src/rpc/mod.rs create mode 100644 src/rpc/test.rs diff --git a/.env.example b/.env.example index e22ca63..816648d 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,20 @@ # Backtraces for local debugging: 1 for a short backtrace, "full" for all frames. # RUST_BACKTRACE=1 -# Example of a credential a live/network-gated test would need. Tests that -# require one must skip cleanly when it is unset. -# EXAMPLE_API_KEY=replace-me +# --- Vercel ----------------------------------------------------------------- +# +# The API key TinyHosts signs Vercel requests with. The `TINYHOSTS_`-prefixed +# name is searched first so a host running several tools can give this crate its +# own token without disturbing the Vercel CLI's. +# +# Create one at https://vercel.com/account/tokens. +# TINYHOSTS_VERCEL_TOKEN=replace-me +# VERCEL_TOKEN=replace-me + +# The team to act on behalf of. Leave unset for a personal account. +# TINYHOSTS_VERCEL_TEAM_ID=team_replace_me +# VERCEL_TEAM_ID=team_replace_me + +# Nothing in the test suite reads any of these: the provider tests run against a +# local mock of the REST API. They are read by `connect_from_env`, and by the +# `basic` example when it is pointed at a real account. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index c808685..94899ae 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - name: Security reports - url: https://github.com/tinyhumansai/rust-template/security/policy + url: https://github.com/tinyhumansai/tinyhosts/security/policy about: Please do not report vulnerabilities through public issues. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d892cb..4c46d1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -248,7 +248,7 @@ jobs: $ErrorActionPreference = 'Stop' $libraryName = $env:CRATE_NAME.Replace('-', '_') $module = "target/release/$libraryName.dll" - $verifyRoot = Join-Path $env:RUNNER_TEMP 'rust-template-module-verify' + $verifyRoot = Join-Path $env:RUNNER_TEMP 'tinyhosts-module-verify' New-Item -ItemType Directory -Force $verifyRoot | Out-Null $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() diff --git a/AGENTS.md b/AGENTS.md index 8d6c047..7b2b65f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,40 +4,26 @@ This file is the single source of truth for how humans and coding agents work in this repository. `CLAUDE.md` is a symlink to this file, so every agent reads the same instructions. -When you generate a new project from this template, keep this file and adapt -the project-specific parts (crate name, module map, feature flags, commands). -Delete guidance that no longer applies rather than leaving it to rot. - -## Template Checklist - -Do this once, in a single commit, before writing feature code: - -- [ ] Set `name`, `description`, `repository`, `keywords`, and `categories` in - `Cargo.toml`. -- [ ] Rename the crate references in `README.md`, `src/lib.rs`, `examples/`, - and `tests/` (search for `rust_template` and `rust-template`). -- [ ] Replace the placeholder `greeting` module with the first real feature - area, keeping the `mod.rs` / `types.rs` / `test.rs` layout. -- [ ] Confirm `license` and `LICENSE` match the project's intended license. -- [ ] Update the security contact in `SECURITY.md`. -- [ ] Replace `ROADMAP.md` with the real plan, or delete it. -- [ ] Rename the TinyBus interface, object path, and declared methods in - `src/tinybus_module/` while keeping `vendor/tinybus` pinned. -- [ ] Rewrite the "Project Structure" section below to describe this crate. - ## Project Structure -This is a Rust 2024 library crate rooted at `Cargo.toml`. +This is a Rust 2024 library crate rooted at `Cargo.toml`. It is both an ordinary +library and a TinyBus module: the `cdylib` is the same code behind a JSON method. ```text src/ ├── lib.rs # crate docs + the entire public re-export surface ├── error/mod.rs # crate-wide `Error` and `Result` -├── tinybus_module/ # TinyBus interface, ABI exports, and integration tests -└── / # one directory per feature area - ├── mod.rs # module docs, wiring, smallest useful public API - ├── types.rs # substantial type definitions - └── test.rs # module-local unit tests +├── credentials/ # the API key: redacted, deserialize-only +├── host/ # the `Host` trait (mod.rs) and the unified vocabulary +│ └── types.rs # every provider-independent type +├── bundle/ # an application's files, and reading them off disk +├── launch/ # the whole flow, in the one order that works +├── providers/ +│ ├── mod.rs # `ProviderKind`, `connect`, `connect_to` +│ └── vercel/ # one adapter: mod.rs (the `Host` impl), +│ # http.rs (status mapping), wire.rs (Vercel's shapes) +├── rpc/ # one JSON request in, one JSON result out +└── tinybus_module/ # TinyBus interface, ABI exports, and integration tests tests/ # integration tests against the public API only examples/ # runnable, compiled-in-CI usage examples vendor/tinybus/ # pinned TinyBus host types and module SDK @@ -47,6 +33,23 @@ docs/ └── adr/ # immutable architecture decision records ``` +The model every provider is held to is +[`docs/specs/unified-hosting-api.md`](docs/specs/unified-hosting-api.md); the +reasoning behind it is ADR 2. Read both before changing the `Host` trait or the +types in `host/types.rs` — they are a contract two other repositories depend on. + +Four rules are load-bearing and must survive any change: + +- **A secret travels one way.** `Credentials` has no `Serialize`; a listed + environment variable has no value; a `Database` reports variable *names*, never + a connection string. Do not add a getter, a `Serialize`, or a log line that + breaks this. +- **A capability a provider lacks returns `Error::Unsupported`.** Never `Ok`. +- **A provider state this crate does not model is carried through** as + `DeploymentStatus::Other` / `Framework::Other`, not mapped onto a known one. +- **`launch`'s order is the specification**, not an implementation detail. The + test that asserts the request sequence is the one that protects it. + Each feature area belongs in a focused module directory under `src/`. A module root explains the module, wires its pieces together, and exposes the smallest useful API. Move substantial type definitions into `types.rs` and put @@ -67,6 +70,18 @@ Keep public exports centralized in `src/lib.rs` so downstream users have one predictable surface. Put shared error variants in `src/error/mod.rs` and return the crate-wide `Result` from fallible public APIs. +### Provider adapters + +A new provider is a directory under `src/providers/`, a `ProviderKind` variant, +and an arm in `connect_to`. Keep the three-file split the Vercel adapter uses: +the `Host` implementation, the HTTP plumbing that owns status-to-error mapping in +one place, and the provider's own request and response shapes. The provider's +vocabulary stops at `wire.rs`; nothing above it should know the word `readyState`. + +Test an adapter against a local mock of its REST API — never the live service and +never a hand-written fake of `Host`, which would only confirm the behavior it was +written to expect. `Vercel::with_base_url` exists for exactly this. + ## Build And Test Run every command from the repository root. These four are the contract; CI @@ -83,7 +98,7 @@ Supporting commands: - `cargo fmt --all` — format before committing. - `cargo test ` — run a focused subset while iterating. -- `cargo run --example basic` — run the bundled example. +- `cargo run --example basic` — build a launch plan without sending it. - `cargo doc --no-deps --all-features` — build the rustdoc CI also builds with `RUSTDOCFLAGS="-D warnings"`. - `cargo test --doc` — run doctests alone when editing documentation examples. diff --git a/Cargo.lock b/Cargo.lock index ad52f44..4262698 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -17,6 +26,16 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -28,6 +47,18 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64" version = "0.23.1" @@ -40,6 +71,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -68,6 +108,41 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -83,6 +158,34 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -94,6 +197,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -153,6 +266,119 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -160,8 +386,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -171,8 +399,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] @@ -181,6 +431,18 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.5.0" @@ -191,12 +453,206 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -207,12 +663,35 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.189" @@ -225,12 +704,24 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "log" version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "memchr" version = "2.8.3" @@ -247,6 +738,27 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -265,6 +777,15 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -274,6 +795,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -289,6 +866,99 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "ring" version = "0.17.14" @@ -304,15 +974,10 @@ dependencies = [ ] [[package]] -name = "rust-template" -version = "0.1.5" -dependencies = [ - "serde_json", - "thiserror", - "tinybus", - "tinybus-module", - "tokio", -] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustix" @@ -348,6 +1013,7 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] @@ -362,6 +1028,18 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "serde" version = "1.0.229" @@ -414,6 +1092,29 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -426,6 +1127,34 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "subtle" version = "2.6.1" @@ -454,6 +1183,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tar" version = "0.4.46" @@ -538,6 +1287,50 @@ dependencies = [ "tracing", ] +[[package]] +name = "tinyhosts" +version = "0.1.5" +dependencies = [ + "async-trait", + "base64 0.22.1", + "hex", + "reqwest", + "serde", + "serde_json", + "sha1", + "tempfile", + "thiserror", + "tinybus", + "tinybus-module", + "tokio", + "wiremock", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.53.1" @@ -545,8 +1338,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -560,6 +1357,30 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -601,6 +1422,51 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -632,6 +1498,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -650,7 +1528,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" dependencies = [ - "base64", + "base64 0.23.1", "flate2", "log", "percent-encoding", @@ -667,24 +1545,132 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" dependencies = [ - "base64", + "base64 0.23.1", "http", "httparse", "log", ] +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf8-zero" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -791,6 +1777,35 @@ dependencies = [ "memchr", ] +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "xattr" version = "1.6.1" @@ -801,12 +1816,89 @@ dependencies = [ "rustix", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "zip" version = "2.4.2" diff --git a/Cargo.toml b/Cargo.toml index dddcbf9..0ff61ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,15 @@ [package] -name = "rust-template" +name = "tinyhosts" version = "0.1.5" edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" -description = "A production-ready template for installable TinyBus modules." -repository = "https://github.com/tinyhumansai/rust-template" -documentation = "https://docs.rs/rust-template" +description = "Unified hosting API for shipping Next.js applications, databases, domains and analytics to a real hosting provider." +repository = "https://github.com/tinyhumansai/tinyhosts" +documentation = "https://docs.rs/tinyhosts" readme = "README.md" -keywords = ["tinybus", "module", "plugin", "template"] -categories = ["development-tools"] +keywords = ["hosting", "vercel", "nextjs", "deploy", "tinybus"] +categories = ["api-bindings", "web-programming"] publish = false # Keep the published package to what a consumer actually needs. exclude = [ @@ -34,18 +34,43 @@ tinybus = { path = "vendor/tinybus/crates/tinybus", version = "0.1.0", default-f # The module-side SDK owns the isolated runtime and exports the ABI entrypoints # required by TinyBus's dynamic loader. tinybus-module = { path = "vendor/tinybus/crates/tinybus-module", version = "0.1.0" } -# Derive macros for the crate-wide error type in `src/error/mod.rs`. Every -# dependency entry should carry a comment like this one saying why it is here. +# Derive macros for the crate-wide error type in `src/error/mod.rs`. thiserror = "2" +# `Host` is consumed as `dyn Host`, and every method performs network I/O, so +# the trait needs boxed async methods to stay object-safe on the MSRV. +async-trait = "0.1" +# The unified model crosses process boundaries twice: over TinyBus, and into the +# OpenCompany front end. Every public type in `host::types` is (de)serializable. +serde = { version = "1", features = ["derive"] } +# Request and response bodies for the provider REST APIs, and the JSON envelope +# the `rpc` module exchanges over the bus. +serde_json = "1" +# Bundle file contents travel as base64 in JSON: a byte array would triple the +# size of every deployment payload crossing the bus. +base64 = "0.22" +# Provider HTTP clients. rustls keeps the build free of a system OpenSSL, and +# `json` covers every request body except the raw file upload. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } +# Vercel identifies uploaded deployment files by their SHA-1 digest +# (`x-vercel-digest`), so the digest algorithm is not a free choice. +sha1 = { version = "0.10", optional = true } +# Renders the SHA-1 digest as the lowercase hex string the upload header wants. +hex = { version = "0.4", optional = true } [dev-dependencies] # Module integration tests exercise the real asynchronous in-memory TinyBus. tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } -# The GitHub release verifier passes an explicit empty module configuration. -serde_json = "1" +# Provider tests run against a local mock of the REST API rather than the live +# service, which keeps the suite deterministic and offline. +wiremock = "0.6" +# `Bundle::from_dir` is tested against a real directory tree. +tempfile = "3" [features] -default = [] +default = ["vercel"] +# The Vercel provider and the HTTP stack it needs. Off, the crate is the +# provider-agnostic model alone: `Host`, its types, `Bundle`, and `launch`. +vercel = ["dep:reqwest", "dep:sha1", "dep:hex"] # Lints apply to the whole crate and to every target. CI runs clippy with # `-D warnings`, so anything set to "warn" here fails the build in CI. diff --git a/MODULE.md b/MODULE.md index f0da19b..6a3845d 100644 --- a/MODULE.md +++ b/MODULE.md @@ -1,12 +1,18 @@ -# Rust Template TinyBus Module +# TinyHosts TinyBus Module -This package contains the native `rust-template` module for TinyBus module ABI -v1. Install only the archive matching the host operating system and -architecture. +This package contains the native `tinyhosts` module for TinyBus module ABI v1. +Install only the archive matching the host operating system and architecture. -The module claims `ai.tinyhumans.rust_template.Greeting`, serves the object at -`/ai/tinyhumans/rust_template/Greeting`, and provides the `Greet` method. The -method accepts one string and returns `Hello, !`; empty names are rejected. +The module claims `ai.tinyhumans.tinyhosts.Hosting`, serves the object at +`/ai/tinyhumans/tinyhosts/Hosting`, and provides two methods: + +- **`Execute`** takes one JSON hosting request and returns one JSON result. The + request names a provider, carries the account's API key, and names an + operation — `launch`, `deploy`, `provision_database`, `set_env`, `analytics` + and the rest of the `Host` surface. A request without a credential falls back + to the environment. See the repository README for the envelope. +- **`Providers`** takes nothing and returns the provider slugs this build can + connect to, as a JSON array. The archive contains one `.so`, `.dylib`, or `.dll` plus `modules.toml`. Keep those files together when copying them into a TinyBus module directory. The @@ -19,10 +25,11 @@ archive. Install directly from a tagged release with: ```sh tinybus modules load-github \ - https://github.com/tinyhumansai/rust-template/releases/tag/v0.1.4 \ - rust-template-0.1.4-ubuntu-24.04-x86_64.tar.gz \ + https://github.com/tinyhumansai/tinyhosts/releases/tag/v0.1.5 \ + tinyhosts-0.1.5-ubuntu-24.04-x86_64.tar.gz \ ``` -TinyBus modules are trusted in-process code. Install release artifacts only -from a trusted source and restart the host after replacing a loaded module. +TinyBus modules are trusted in-process code, and this one is handed live hosting +credentials by its callers. Install release artifacts only from a trusted source, +and restart the host after replacing a loaded module. diff --git a/README.md b/README.md index 2d5f3ff..e6c8696 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,128 @@ -# Rust Template - -A production-ready Rust 2024 TinyBus module template used by TinyHumans AI. It -ships the module layout, TinyBus ABI adapter, error handling, testing, -documentation, CI, and multi-platform release workflow that every new -integration in this organization starts from. - -## Use This Template - -Choose **Use this template** on GitHub, create a repository, then work through -the checklist at the top of [`AGENTS.md`](AGENTS.md): - -- update the package name, description, repository, keywords, and categories in - `Cargo.toml`; -- update this README and the crate documentation in `src/lib.rs`; -- replace the placeholder `greeting` module with the first real feature area; -- rename the TinyBus interface, object path, and exported methods in - `src/tinybus_module/`; -- update the security contact and repository links in the community files; -- replace `ROADMAP.md` with the real plan, or delete it; -- change the license if GPL-3.0-only is not appropriate. - -Search for `rust-template` and `rust_template` to find every remaining -template-specific value. - -## What You Get - -| Area | What is configured | -| --- | --- | -| Layout | Directory modules with `mod.rs` / `types.rs` / `test.rs`, a crate-wide error type, integration tests, and a runnable example | -| Lints | `unsafe_code` forbidden, `missing_docs`, clippy `all` + `pedantic`, no `unwrap`/`expect`/`panic`/`todo` in library code — all declared in `[lints]` so local and CI runs agree | -| CI | Format, clippy, build, test (default and all features), at least 90% line coverage in every source file, rustdoc with `-D warnings`, an MSRV build, and a `cargo-deny` supply-chain check | -| Release | Manual `workflow_dispatch` bump that validates, versions, tags, and creates installable native module packages for every supported platform | -| Community | Issue and pull request templates, Dependabot, contributing, security, support, and code of conduct docs | -| Agents | [`AGENTS.md`](AGENTS.md) as the single source of truth, symlinked as `CLAUDE.md`, plus a `.claude/settings.json` allowlist for the standard commands | -| Vendor | TinyBus host types and module SDK pinned as the `vendor/tinybus` build-time submodule | +# TinyHosts + +One API for putting a Next.js application — and the database behind it — on a +real hosting provider. + +TinyHosts is the hosting category of the TinyHumans stack. OpenHuman vendors it, +OpenCompany inherits it from there, and a user who pastes a provider API key into +OpenCompany gets a live site out of a workspace. It ships both as an ordinary +Rust library and as an installable TinyBus module. + +Vercel is the first provider. Adding the next one means implementing one trait. + +## What it does + +The unit of work is the whole thing, because that is what "host this" means: + +| Step | Method | On Vercel | +| --- | --- | --- | +| Site | `create_site`, `find_site`, `list_sites` | a project | +| Database | `provision_database`, `attach_database` | a marketplace store, connected to the project | +| Environment | `set_env`, `list_env` | project environment variables | +| Domain | `add_domain`, `list_domains` | a project domain | +| Deployment | `deploy`, `deployment`, `list_deployments`, `promote` | file upload, then a build | +| Traffic | `analytics` | the web analytics query API | + +[`launch`](src/launch/mod.rs) runs all six in the one order that works — the +database is connected *before* the build, because a Next.js build reads its +environment at build time. + +## Using it + +```rust +use tinyhosts::{Bundle, DatabaseSpec, LaunchPlan, ProviderKind, SiteSpec, launch}; + +let host = tinyhosts::connect_from_env(ProviderKind::Vercel)?; + +let plan = LaunchPlan::new(SiteSpec::new("shop"), Bundle::from_dir("./shop")?) + .with_database(DatabaseSpec::new("shop-db")) + .into_production(); + +let result = launch(host.as_ref(), &plan).await?; +println!("building at {:?}", result.url()); +``` + +`launch` returns while the build is still running. Poll `Host::deployment` until +its status `is_terminal()`; how long to wait is the caller's policy, so this +crate owns no timer. + +Credentials come from `TINYHOSTS_VERCEL_TOKEN`, falling back to `VERCEL_TOKEN`, +with `TINYHOSTS_VERCEL_TEAM_ID` / `VERCEL_TEAM_ID` for a team account. See +[`.env.example`](.env.example). A credential can equally be passed in from a +form, which is what OpenCompany does. + +### From another process + +`tinyhosts::execute_json` is the same surface as one JSON request and one JSON +result, and the TinyBus `Execute` method is a thin wrapper over it: + +```json +{ + "provider": "vercel", + "credentials": { "api_key": "...", "team": null }, + "operation": "launch", + "plan": { + "site": { "name": "shop" }, + "bundle": [{ "path": "package.json", "contents": "e30=" }], + "database": { "name": "shop-db", "kind": "postgres" }, + "target": "production" + } +} +``` + +Bundle file contents are base64. Results are `{"result": "...", "value": ...}`. + +## What it will not do + +- **Hold a secret.** A database's connection string is injected by the provider + into the site's environment; this crate only ever learns the *names* of the + variables. `Credentials` has no `Serialize` and a redacting `Debug`. +- **Pretend.** A capability a provider lacks is an `Unsupported` error naming the + provider and the capability, never a silent success. +- **Wait, retry, or schedule.** Those are the caller's policy. + +## Databases + +Vercel does not run databases; its marketplace partners do. `provision_database` +therefore searches the installed integrations on the account for a product that +serves the requested kind — `postgres` matches Neon, Supabase, Prisma Postgres +and friends — creates a store from it, and connects it to the project. If nothing +on the account can serve the kind, the error says exactly that rather than +failing later with a missing `DATABASE_URL`. + +Pin a specific product with `DatabaseSpec::with_product` when an account has more +than one that would match. + +## Adding a provider + +Implement `Host`, add a `ProviderKind` variant, and wire it into `connect_to`. +[`docs/specs/unified-hosting-api.md`](docs/specs/unified-hosting-api.md) maps the +model onto Netlify, Cloudflare, Railway, Render, Fly.io and a self-hosted target, +and says where each one does not fit. ## Layout ```text src/ -├── lib.rs # crate docs + the entire public re-export surface -├── error/ -│ ├── mod.rs # crate-wide `Error` and `Result` -│ └── test.rs -├── greeting/ # one directory per feature area - ├── mod.rs # module docs, wiring, smallest useful public API - └── test.rs # module-local unit tests -└── tinybus_module/ - ├── mod.rs # bus interface, setup, and ABI v1 exports - └── test.rs # real in-memory TinyBus integration tests -tests/ -└── public_api.rs # integration tests against the public API only -examples/ -├── basic.rs # ordinary library API usage -├── verify_module.rs # local dynamic-module verification -└── verify_github_release.rs # tagged-release download and bus call -vendor/ -└── tinybus/ # pinned TinyBus git submodule -docs/ -├── README.md # documentation index and conventions -├── specs/ # behavior and architecture specifications -├── plans/ # implementation-ordered delivery plans -└── adr/ # immutable architecture decision records +├── lib.rs # crate docs + the public re-export surface +├── error/ # crate-wide `Error` and `Result` +├── credentials/ # the API key, redacted and write-only +├── host/ # the `Host` trait and the unified vocabulary +│ ├── mod.rs +│ └── types.rs +├── bundle/ # an application's files, and reading them off disk +├── launch/ # the whole flow, in the order that works +├── providers/ +│ ├── mod.rs # `ProviderKind`, `connect`, `connect_to` +│ └── vercel/ # the Vercel adapter: `mod.rs`, `http.rs`, `wire.rs` +├── rpc/ # one JSON request in, one JSON result out +└── tinybus_module/ # bus interface, setup, and ABI v1 exports +tests/public_api.rs # integration tests against the public API only +examples/ # runnable, compiled-in-CI usage examples +vendor/tinybus/ # pinned TinyBus git submodule +docs/{specs,plans,adr}/ ``` -Feature areas use directory modules: implementation and exports live in -`mod.rs`, substantial types move to `types.rs`, and unit tests live in -`test.rs`. [`AGENTS.md`](AGENTS.md) holds the complete repository guidance, and -`CLAUDE.md` is a symlink to it so every coding agent reads one source of truth. - ## Development Clone with submodules, or initialize them before building: @@ -82,47 +136,44 @@ cargo fmt --all -- --check cargo clippy --all-targets --all-features -- -D warnings cargo build --all-targets --all-features cargo test --all-features -cargo run --example basic -cargo build --release --lib # produces the installable cdylib ``` -Those four checks are exactly what CI runs. Optional extras: +Those four are exactly what CI runs. Optional extras: ```sh +cargo run --example basic # build a launch plan, send nothing cargo doc --no-deps --all-features # CI builds this with RUSTDOCFLAGS="-D warnings" cargo deny check all # supply-chain check; see deny.toml cargo install cargo-llvm-cov # once, before running the coverage gate -.github/scripts/check-file-coverage.sh 90 target/coverage.json +.github/scripts/check-file-coverage.sh 90 coverage.json ``` +The provider tests run the real adapter against a local mock of the REST API, so +the suite is offline, deterministic, and needs no token. + ## Releasing Run the **Release** workflow from the Actions tab with a `patch`, `minor`, or `major` bump. Use `current` only to resume an interrupted release whose version commit and tag already exist. The workflow revalidates the crate, versions and tags it, builds this crate as a TinyBus `cdylib`, and creates a GitHub release. -Assets follow `rust-template--.` and contain the +Assets follow `tinyhosts--.` and contain the native module, its SHA-256 `modules.toml`, license, and [`MODULE.md`](MODULE.md). Every release also publishes `checksum.toml`, which TinyBus uses to verify an archive before extraction. The workflow loads the published Ubuntu archive through TinyBus's GitHub release API and calls its -`Greet` method before declaring the release successful. TinyBus itself is not -shipped by this repository; the pinned submodule is the build-time SDK. The stable native -matrix covers Ubuntu 22.04 and 24.04 on x86_64 and ARM64; Fedora 43 and 44 on -x86_64 and ARM64; rolling Arch Linux on its officially supported x86_64 -architecture; macOS 15 and 26 on Intel and Apple Silicon; Windows Server 2022 -and 2025 on x86_64; and Windows 11 on ARM64. Preview, deprecated, and unofficial -architecture images are not release gates. Do not hand-edit the version in -`Cargo.toml`. +`Providers` method before declaring the release successful. TinyBus itself is not +shipped by this repository; the pinned submodule is the build-time SDK. Do not +hand-edit the version in `Cargo.toml`. ## Documentation - [`AGENTS.md`](AGENTS.md) — repository guidelines for humans and agents -- [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to propose a change -- [`docs/specs/`](docs/specs/README.md) — behavior and architecture specs -- [`docs/plans/`](docs/plans/README.md) — test-first implementation plans +- [`docs/specs/unified-hosting-api.md`](docs/specs/unified-hosting-api.md) — the + model, and how it maps onto other providers - [`docs/adr/`](docs/adr/0001-record-architecture-decisions.md) — architecture decision records +- [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to propose a change - [`SECURITY.md`](SECURITY.md) — how to report a vulnerability ## License diff --git a/ROADMAP.md b/ROADMAP.md index 1134024..e0f703f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,26 +1,30 @@ # Roadmap -Replace this file with the real plan for the crate generated from this -template, or delete it if the project does not need a public roadmap. - -Keep it short and honest: what exists, what is next, and what is deliberately -out of scope. A roadmap that lists everything is a roadmap nobody trusts. +What exists, what is next, and what is deliberately out of scope. ## Shipped -- module layout, crate-wide error type, and the public re-export surface -- lint configuration in `[lints]`, enforced identically locally and in CI -- CI: format, clippy, build, test, per-file coverage, rustdoc, MSRV, and - supply-chain checks -- a manual release workflow that versions, tags, publishes to crates.io, and - creates a GitHub release with crate and TinyBus runtime/module assets +- The unified hosting model: `Host`, its vocabulary, `Bundle`, and `launch`. +- The Vercel adapter: projects, non-Git deployments, environment variables, + marketplace databases, domains, promotion, and web analytics. +- `rpc`: one JSON request in, one JSON result out, and the TinyBus module over + it. ## Next -- the first real feature area, replacing the placeholder `greeting` module -- module-level `README.md` and `docs/spec/` entries as modules grow +- A second provider, to find the Vercel-shaped assumptions in the vocabulary. + Railway first — the same model with a first-party database — then Cloudflare, + whose bindings are the model's real stress test. See + [`docs/specs/unified-hosting-api.md`](docs/specs/unified-hosting-api.md). +- A self-hosted target, which is what proves the model is not shaped around a + platform. +- Deployment log streaming: a failed build currently reports a message, not the + build output that explains it. ## Out Of Scope -- anything that cannot be tested deterministically -- convenience wrappers that hide the crate's error taxonomy from callers +- Waiting, retrying, or scheduling. How long a caller will wait for a build is + the caller's policy, and a hidden one cannot be cancelled or reported on. +- Holding a connection string, or any secret the provider injects itself. +- Wrapping a provider's whole API. The model covers shipping and running an + application; anything beyond that is reached through the provider's own client. diff --git a/docs/README.md b/docs/README.md index 0c0f2b1..6a3775c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,7 +28,11 @@ docs/ Complex modules also carry a module-level `README.md` inside `src//` covering their design, public surface, and important constraints. -The current module-release contract is in +The model every provider is held to is in +[`specs/unified-hosting-api.md`](specs/unified-hosting-api.md), and the reasoning +behind it in +[`adr/0002-a-provider-agnostic-hosting-model.md`](adr/0002-a-provider-agnostic-hosting-model.md). +The module-release contract is in [`specs/tinybus-module-release.md`](specs/tinybus-module-release.md), with its implementation sequence in [`plans/tinybus-module-release.md`](plans/tinybus-module-release.md). diff --git a/docs/adr/0002-a-provider-agnostic-hosting-model.md b/docs/adr/0002-a-provider-agnostic-hosting-model.md new file mode 100644 index 0000000..a9f70c1 --- /dev/null +++ b/docs/adr/0002-a-provider-agnostic-hosting-model.md @@ -0,0 +1,59 @@ +# 2. A provider-agnostic hosting model, with credentials that never come back + +- **Status:** Accepted +- **Date:** 2026-08-18 + +## Context + +TinyHosts exists so a workspace can become a live website. The obvious +implementation is a Vercel client: OpenCompany takes a token, this crate calls +`api.vercel.com`, a site appears. That implementation is also the one that has to +be thrown away the first time somebody wants Cloudflare, or a self-hosted box, or +a database Vercel does not sell. + +Two further facts shaped the design. Hosting is not one call — a working Next.js +application needs a site, a database wired into it, an environment, a domain and +a build, in that order. And the credential is a user's, pasted into a form, +crossing a process boundary on its way here. + +## Decision + +**One trait, `Host`, is the whole contract**, spoken in a provider-independent +vocabulary; `ProviderKind` plus `connect_to` makes the provider a configuration +value. Vercel is an adapter behind it, not the API. + +**`launch` owns the order**, because the order is the part that is easy to get +wrong and impossible to debug: a database attached after the build is invisible +to the built pages. + +**Secrets travel one way.** `Credentials` implements `Deserialize` but not +`Serialize`, and renders as ``. `EnvVar` carries a value; the record +returned by a list call does not. A `Database` reports the *names* of the +variables the provider injects and never a connection string — the values are +injected provider-side, so this crate has no reason to hold one and therefore no +way to leak one. + +**A capability a provider lacks is an error.** `Error::Unsupported` names the +provider and the capability rather than returning `Ok`. + +**A state the model does not know is carried through.** `DeploymentStatus::Other` +and `Framework::Other` keep the provider's own word rather than being mapped onto +the nearest known value. + +## Consequences + +- A second provider is a new module and a `ProviderKind` variant. Nothing above + the trait changes. `docs/specs/unified-hosting-api.md` maps six candidates. +- The model is deliberately narrow: it covers shipping and running a Next.js + application and nothing else. Anything further is reached through a provider's + own client. +- Vercel-shaped assumptions may still be hiding in the vocabulary. The cheapest + way to find them is a second adapter over a provider with first-party + databases; Cloudflare's *bindings* are the known stress test, because a binding + is not an environment variable. +- `attach_database` returning names rather than values means a caller cannot run + a migration from the connection string it just created. That is the intended + trade: the provider injects it, the application reads it, and nothing in + between has to be trusted with it. +- A launch is not transactional, so a failed build can leave a paid database + behind. Deleting and recreating it on every retry was judged the worse failure. diff --git a/docs/plans/README.md b/docs/plans/README.md index 0a5db16..37473d7 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -19,4 +19,4 @@ Prefer tasks that can be implemented and reviewed independently. Include short code snippets when they remove ambiguity, but do not paste entire future files into the plan. -See [`example-retry-policy.md`](example-retry-policy.md) for a test-first sample. +See [`tinybus-module-release.md`](tinybus-module-release.md) for a worked example. diff --git a/docs/plans/example-retry-policy.md b/docs/plans/example-retry-policy.md deleted file mode 100644 index 0590963..0000000 --- a/docs/plans/example-retry-policy.md +++ /dev/null @@ -1,71 +0,0 @@ -# Example plan: Retry policy - -- **Status:** Example -- **Specification:** - [`../specs/example-retry-policy.md`](../specs/example-retry-policy.md) - -> This is a sample implementation plan, not active work. Replace or remove it -> when generating a real project from this template. - -## Goal - -Add the specified typed retry policy through small red-green-refactor steps, -without adding a runtime, timers, or new dependencies. - -## Task 1: Add the constructor contract - -**Files:** `src/retry/mod.rs`, `src/retry/types.rs`, `src/retry/test.rs` - -1. Create the module skeleton and a failing test for zero attempts: - - ```rust - #[test] - fn rejects_zero_max_attempts() { - assert_eq!( - RetryPolicy::new(0).unwrap_err(), - Error::ZeroMaxAttempts, - ); - } - ``` - -2. Add `Error::ZeroMaxAttempts` in `src/error/mod.rs` and its message assertion - in `src/error/test.rs`. -3. Implement `RetryPolicy::new` using `NonZeroU32`, keeping the field private. -4. Run `cargo test retry` and `cargo clippy --all-targets --all-features -- -D warnings`. - -## Task 2: Add attempt-boundary behavior - -**Files:** `src/retry/mod.rs`, `src/retry/test.rs` - -1. Add failing tests for attempts `0`, `1`, the maximum, and one past it. -2. Implement the smallest boundary check: - - ```rust - #[must_use] - pub fn allows_attempt(self, attempt: u32) -> bool { - attempt != 0 && attempt <= self.max_attempts.get() - } - ``` - -3. Run `cargo test retry`. - -## Task 3: Publish and document the API - -**Files:** `src/lib.rs`, `tests/public_api.rs`, `README.md` - -1. Re-export `RetryPolicy` from `src/lib.rs`. -2. Add an integration test using only `rust_template::{Error, RetryPolicy}`. -3. Add a runnable README example and rustdoc `# Errors` documentation. -4. Run `cargo test --doc` and `cargo test --test public_api`. - -## Task 4: Full verification - -- [ ] `cargo fmt --all -- --check` -- [ ] `cargo clippy --all-targets --all-features -- -D warnings` -- [ ] `cargo build --all-targets --all-features` -- [ ] `cargo test --all-features` -- [ ] `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features` -- [ ] `cargo deny check all` - -When all checks pass, mark the specification Implemented and replace this -example status with the actual completion state. diff --git a/docs/plans/tinybus-module-release.md b/docs/plans/tinybus-module-release.md index 66c1dcf..7c60256 100644 --- a/docs/plans/tinybus-module-release.md +++ b/docs/plans/tinybus-module-release.md @@ -3,9 +3,9 @@ Linked specification: [`../specs/tinybus-module-release.md`](../specs/tinybus-module-release.md) 1. Add the pinned TinyBus host types and module SDK as path dependencies. -2. Export the template greeting behavior through TinyBus module ABI v1. +2. Export the hosting surface through TinyBus module ABI v1. 3. Exercise the declared interface over the real in-memory bus. -4. Replace TinyBus host bundles with tagged `rust-template` module archives for +4. Replace TinyBus host bundles with tagged `tinyhosts` module archives for every supported platform runner and distribution container. 5. Run the repository validation and coverage contracts, push `main`, and trigger a patch release. diff --git a/docs/specs/README.md b/docs/specs/README.md index a8286ae..61fbab5 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -20,4 +20,4 @@ After the specification is accepted, create a linked implementation plan in [`../plans/`](../plans/README.md). Keep code snippets small enough to clarify the contract; production code still belongs under `src/`. -See [`example-retry-policy.md`](example-retry-policy.md) for a complete sample. +See [`unified-hosting-api.md`](unified-hosting-api.md) for a complete example. diff --git a/docs/specs/example-retry-policy.md b/docs/specs/example-retry-policy.md deleted file mode 100644 index 39f399d..0000000 --- a/docs/specs/example-retry-policy.md +++ /dev/null @@ -1,63 +0,0 @@ -# Example: Retry policy - -- **Status:** Example -- **Owner:** Maintainers -- **Plan:** [`../plans/example-retry-policy.md`](../plans/example-retry-policy.md) - -> This demonstrates the expected specification format. Replace or remove it -> when generating a real project from this template. - -## Problem - -Callers need a typed way to limit retries without duplicating attempt counting -and validation. The crate currently has no retry behavior. - -## Goals - -- Expose an immutable retry policy with a non-zero maximum attempt count. -- Let callers determine whether another attempt is permitted. -- Reject zero attempts through the crate-wide error type. - -## Non-goals - -- Sleeping, backoff, jitter, or executing operations. -- Deciding which application-specific errors are retryable. -- Persisting retry state. - -## Proposed behavior - -The public surface is deliberately small: - -```rust -use rust_template::{RetryPolicy, Result}; - -fn policy() -> Result { - let policy = RetryPolicy::new(3)?; - assert!(policy.allows_attempt(1)); - assert!(!policy.allows_attempt(4)); - Ok(policy) -} -``` - -`RetryPolicy::new(0)` returns a dedicated `Error::ZeroMaxAttempts` variant. -Attempt numbers are one-based: attempt `1` is the initial call, not the first -retry. - -## Invariants and constraints - -- `max_attempts` is always greater than zero after construction. -- `allows_attempt(n)` is true exactly when `1 <= n <= max_attempts`. -- The type is cheap to copy and does not perform I/O or observe time. -- New public items have rustdoc and are re-exported from `src/lib.rs`. - -## Acceptance criteria - -- Construction succeeds for `1` and `u32::MAX` and fails for `0`. -- Boundary checks cover attempts `0`, `1`, `max_attempts`, and - `max_attempts + 1` when representable. -- Integration tests prove the policy and its error are available to consumers. -- Formatting, Clippy, build, tests, rustdoc, and cargo-deny pass. - -## Open questions - -None for this example. diff --git a/docs/specs/tinybus-module-release.md b/docs/specs/tinybus-module-release.md index d4f3a76..6dde010 100644 --- a/docs/specs/tinybus-module-release.md +++ b/docs/specs/tinybus-module-release.md @@ -10,10 +10,10 @@ distributable without also shipping the TinyBus host runtime. - The library builds as both an `rlib` and a native `cdylib`. - The `cdylib` exports TinyBus module ABI v1, an embedded manifest, and the initialization entrypoint. -- The example module provides `ai.tinyhumans.rust_template.Greeting.Greet` at - `/ai/tinyhumans/rust_template/Greeting`. +- The example module provides `ai.tinyhumans.tinyhosts.Hosting.Providers` at + `/ai/tinyhumans/tinyhosts/Hosting`. - Each release archive is named - `rust-template--.` and contains only this + `tinyhosts--.` and contains only this module, its SHA-256 `modules.toml`, license, and installation documentation. - Each GitHub release publishes a separate `checksum.toml` mapping every archive filename to its SHA-256 digest for TinyBus's release loader. @@ -29,5 +29,5 @@ CI exercises the bus interface through TinyBus's in-memory transport, enforces 90% line coverage in every source file, and builds the `cdylib`. The release workflow builds each native module from the tagged source and records its exact digest in the adjacent allowlist. After publishing, it downloads the Ubuntu -x86_64 archive through TinyBus's GitHub release API and calls `Greet` over an +x86_64 archive through TinyBus's GitHub release API and calls `Providers` over an in-memory bus. diff --git a/docs/specs/unified-hosting-api.md b/docs/specs/unified-hosting-api.md new file mode 100644 index 0000000..f018166 --- /dev/null +++ b/docs/specs/unified-hosting-api.md @@ -0,0 +1,179 @@ +# The unified hosting API + +**Status:** accepted. Implemented for Vercel. + +This is the standard TinyHosts holds every provider to: what a hosting provider +must be able to do for a Next.js application with a database behind it, said in +one vocabulary, so the provider is a configuration value rather than a rewrite. + +## The problem it solves + +Shipping an application is six things, not one: + +1. a **site** to deploy to; +2. a **database** it can reach; +3. the **environment** it reads; +4. a **domain** in front of it; +5. a **deployment** that builds and serves it; +6. the **traffic** it served afterwards. + +Every provider does all six, and no two name them the same. A caller that +integrates against one provider's API has hard-coded not just its endpoints but +its shape — Vercel's "project" against Netlify's "site", Vercel's marketplace +store against Railway's first-party Postgres. The unified model is the smallest +vocabulary all of them can be said in. + +## The model + +| Concept | Type | What it is | +| --- | --- | --- | +| Site | `Site` / `SiteSpec` | A named application on an account. | +| Bundle | `Bundle` | The application's files, relative paths, contents in memory. | +| Deployment | `Deployment` | One build of a bundle, with a URL and a status. | +| Target | `DeploymentTarget` | `preview` or `production`. | +| Environment | `EnvVar` / `EnvVarRecord` | A name and, outbound only, a value. | +| Database | `DatabaseSpec` / `Database` | A managed store of a `DatabaseKind`. | +| Domain | `Domain` | A custom hostname on a site. | +| Analytics | `AnalyticsQuery` / `AnalyticsSummary` | Traffic over a window. | + +Four rules hold the model honest: + +- **A value never comes back.** `EnvVar` carries a value outbound; `EnvVarRecord` + has none. A database reports `secret_keys` — the *names* of the variables the + provider injects — never a connection string. If the model could return a + secret, every caller and every log line would become a place one could leak. +- **A status this crate does not model is reported, not mapped.** + `DeploymentStatus::Other` and `Framework::Other` carry the provider's own word. + Mapping an unknown state onto a known one is how a poller decides a live site + failed. +- **A missing capability is an error, not a no-op.** `Error::Unsupported` names + the provider and the capability. A stub returning `Ok` for a database it never + created is discovered by an application whose `DATABASE_URL` is missing. +- **A missing site is `Ok(None)`.** "Create it if it is not there" is the common + path and must not be written by catching an error. + +## The order a launch runs in + +`launch` is the standard. The steps do not commute: + +1. **Site** — created only if `find_site` does not find it, so a relaunch + redeploys rather than failing on a name already taken. +2. **Database** — provisioned, then *connected to the site*. Connecting is what + puts the connection variables into the site's environment. +3. **Environment** — after the database, so an explicit variable overrides an + injected one rather than the reverse. +4. **Domains** — before the deployment, so a production build is aliased to them + as it goes live. +5. **Deployment** — last. A Next.js build reads the environment at build time; a + database attached afterwards is one the built pages cannot see. + +A launch is not transactional. A database provisioned before a failing build +stays provisioned, because deleting and recreating it is the more expensive +mistake. + +## Databases are a protocol, not a product + +`DatabaseKind` names a protocol — `postgres`, `redis`, `blob` — and each kind +carries `product_hints`, the fragments a vendor's naming actually uses. A managed +Postgres is rarely called "postgres": it is Neon, Supabase, Prisma, Timescale. +Matching a kind to a product means matching against those names, on the product +slug, the product name, and the installation slug together. + +`DatabaseSpec::product` pins an exact product and overrules the hints, for the +account that has several that would match. + +## Vercel + +| Concept | Endpoint | +| --- | --- | +| Create site | `POST /v11/projects` | +| Find / list sites | `GET /v9/projects/{idOrName}`, `GET /v10/projects` | +| Environment | `POST /v10/projects/{id}/env?upsert=true`, `GET /v10/projects/{id}/env` | +| Database: find product | `GET /v1/integrations/configurations?view=account`, then `GET /v1/integrations/configuration/{id}/products` | +| Database: provision | `POST /v1/storage/stores/integration/direct` | +| Database: attach | `POST /v1/integrations/installations/{icfg}/resources/{id}/connections` | +| Domains | `POST /v10/projects/{id}/domains`, `GET /v9/projects/{id}/domains` | +| Deploy | `POST /v2/files` per file, then `POST /v13/deployments` | +| Deployment status | `GET /v13/deployments/{id}`, `GET /v7/deployments` | +| Promote | `POST /v10/projects/{projectId}/promote/{deploymentId}` | +| Analytics | `GET /v1/query/web-analytics/visits/{count,aggregate}` | + +Three details are Vercel's, and are the reason the adapter exists: + +- **Deployment is upload-then-build, not Git.** Each file is uploaded to + `POST /v2/files` keyed by its **SHA-1** digest (`x-vercel-digest` defines the + algorithm), and the deployment references the digests. This is what makes a + workspace with no repository behind it deployable. +- **A preview omits the target.** Vercel reads an absent `target` as a preview + and rejects the literal string. +- **Databases are marketplace resources.** Vercel runs none itself, which is why + provisioning is a three-request search-and-create rather than one call, and why + `Error::NoDatabaseProduct` exists. + +## Other providers + +Suggestions for what to implement next, and where each one does not fit the model +cleanly. Nothing below is implemented. + +### Netlify — closest fit + +Sites, deploys, env vars and domains map almost one to one, and the deploy API is +the same shape: `POST /api/v1/sites/{id}/deploys` with a `files` map of +**SHA-1** digests, then upload the missing ones. Next.js runs through the Next +Runtime. **No first-party database** — a Postgres comes from Neon or Supabase +directly, so `provision_database` would either call those vendors' own APIs or +return `Unsupported`. Analytics are a paid add-on with a narrower API. + +### Cloudflare (Workers / Pages) — best database story + +`Workers` with `OpenNext` runs Next.js, and Cloudflare *does* run the databases: +D1 (SQLite), Hyperdrive (pooled Postgres), KV, R2 — all first-party REST APIs, so +`provision_database` is a single call and `attach_database` is a binding rather +than an environment variable. That is the mismatch worth knowing about: a binding +is not a `DATABASE_URL`, so `attach_database` would return binding names and the +application code has to be written for them. Deployment is a Wrangler-style +upload of a built worker, so the bundle would be build output rather than source. + +### Railway — one provider, everything included + +Postgres, MySQL, Redis and Mongo are first-party one-call provisions that *do* +produce a `DATABASE_URL`, which makes it the best fit for the model after Vercel. +The API is GraphQL rather than REST, so the adapter carries a query document +instead of paths. Deployment normally comes from a repository; the upload path +exists but is less travelled than Vercel's. + +### Render — closest to a traditional host + +First-party managed Postgres and Redis with connection strings, blueprint-driven +services, a straightforward REST API. Deploys are Git- or image-driven, so a +source bundle would need an intermediate repository or registry push — the one +place the model would have to grow. + +### Fly.io — the escape hatch + +Machines running a container, Fly Postgres or a Managed Postgres alongside. +Everything the model needs exists, but a deployment is a container image build and +push, not a file upload, so `Bundle` would have to mean "build context" and the +adapter would own a builder. Worth doing when someone needs a region or a runtime +the platforms above do not offer. + +### AWS Amplify / Azure Static Web Apps — enterprise fit + +Both host Next.js and both have managed databases nearby (RDS, Cosmos), but +neither pairs them: provisioning a database is a separate service with its own +IAM story. `provision_database` would be a substantially larger piece of work +than the rest of the adapter combined. + +### Self-hosted — the one worth building second + +A Docker or Kubernetes target where `deploy` builds an image and rolls it out, and +`provision_database` runs a Postgres container or claims one from an operator. It +is the only target that answers "what if the user does not want a hosting bill", +and implementing it is what would prove the model is not shaped around Vercel. + +## Recommendation + +Vercel first (done), then **Railway** — the same model with a genuinely +first-party database, which is the cheapest way to find out which parts of the +vocabulary are Vercel-shaped. Then **Cloudflare**, whose bindings are the model's +real stress test, then a **self-hosted** target. diff --git a/examples/basic.rs b/examples/basic.rs index 6fa02b8..ee6cbbe 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -1,22 +1,61 @@ -//! Minimal end-to-end usage of the crate. +//! Describing a launch, without sending it anywhere. //! //! Examples are compiled and linted in CI, so they cannot drift from the API. -//! Run it with: +//! This one builds the plan and stops short of the network, so it runs on a +//! machine with no hosting credentials: //! //! ```sh //! cargo run --example basic //! ``` +//! +//! To actually ship it, set `TINYHOSTS_VERCEL_TOKEN` and add: +//! +//! ```no_run +//! # use tinyhosts::{LaunchPlan, ProviderKind}; +//! # async fn ship(plan: LaunchPlan) -> tinyhosts::Result<()> { +//! let host = tinyhosts::connect_from_env(ProviderKind::Vercel)?; +//! let launched = tinyhosts::launch(host.as_ref(), &plan).await?; +//! println!("building at {:?}", launched.url()); +//! # Ok(()) +//! # } +//! ``` -use rust_template::{Result, greet}; +use tinyhosts::{Bundle, DatabaseSpec, EnvVar, LaunchPlan, ProviderKind, Result, SiteSpec}; fn main() -> Result<()> { - println!("{}", greet("Rust")?); + let mut bundle = Bundle::new(); + bundle.insert("package.json", br#"{"name":"shop","private":true}"#)?; + bundle.insert( + "app/page.tsx", + b"export default function Page() { return

Shop

; }", + )?; - // Failure modes are part of the public contract; show them too. - match greet(" ") { - Ok(greeting) => println!("{greeting}"), + let plan = LaunchPlan::new(SiteSpec::new("shop"), bundle) + .with_database(DatabaseSpec::new("shop-db")) + .with_env(vec![EnvVar::new("NEXT_PUBLIC_NAME", "Shop")]) + .with_domains(vec!["shop.example".to_owned()]) + .into_production(); + + plan.validate()?; + println!( + "{} files, {} bytes, database {:?}, target {}", + plan.bundle.len(), + plan.bundle.total_bytes(), + plan.database.as_ref().map(|database| &database.name), + plan.target.as_str(), + ); + + // Failure modes are part of the public contract; show one. + match LaunchPlan::new(SiteSpec::new("shop"), Bundle::new()).validate() { + Ok(()) => println!("an empty bundle was accepted, which should not happen"), Err(error) => println!("expected failure: {error}"), } + println!("providers in this build: {:?}", tinyhosts::rpc::providers()); + println!( + "credentials come from {:?}", + ProviderKind::Vercel.api_key_variables() + ); + Ok(()) } diff --git a/examples/verify_github_release.rs b/examples/verify_github_release.rs index 3752fc8..fdec6d9 100644 --- a/examples/verify_github_release.rs +++ b/examples/verify_github_release.rs @@ -4,8 +4,8 @@ //! //! ```text //! cargo run --example verify_github_release -- \ -//! https://github.com/tinyhumansai/rust-template/releases/tag/v0.1.4 \ -//! rust-template-0.1.4-ubuntu-24.04-x86_64.tar.gz \ +//! https://github.com/tinyhumansai/tinyhosts/releases/tag/v0.1.5 \ +//! tinyhosts-0.1.5-ubuntu-24.04-x86_64.tar.gz \ //! //! ``` @@ -17,8 +17,8 @@ use tinybus::broker::Broker; use tinybus::module::ModuleHost; use tinybus::transport::memory::MemoryBus; -const INTERFACE: &str = "ai.tinyhumans.rust_template.Greeting"; -const OBJECT_PATH: &str = "/ai/tinyhumans/rust_template/Greeting"; +const INTERFACE: &str = "ai.tinyhumans.tinyhosts.Hosting"; +const OBJECT_PATH: &str = "/ai/tinyhumans/tinyhosts/Hosting"; #[tokio::main] async fn main() -> Result<(), Box> { @@ -56,12 +56,11 @@ async fn main() -> Result<(), Box> { .await??; let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; - let greeting: String = proxy.call("Greet", ("TinyBus",)).await?; - if greeting != "Hello, TinyBus!" { - return Err(io::Error::other(format!( - "module returned an unexpected greeting: {greeting}" - )) - .into()); + let providers: String = proxy.call("Providers", ()).await?; + if !providers.contains("vercel") { + return Err( + io::Error::other(format!("module reported unexpected providers: {providers}")).into(), + ); } println!( diff --git a/examples/verify_module.rs b/examples/verify_module.rs index 3b8ae2e..87d34da 100644 --- a/examples/verify_module.rs +++ b/examples/verify_module.rs @@ -9,8 +9,8 @@ use tinybus::broker::Broker; use tinybus::module::ModuleHost; use tinybus::transport::memory::MemoryBus; -const INTERFACE: &str = "ai.tinyhumans.rust_template.Greeting"; -const OBJECT_PATH: &str = "/ai/tinyhumans/rust_template/Greeting"; +const INTERFACE: &str = "ai.tinyhumans.tinyhosts.Hosting"; +const OBJECT_PATH: &str = "/ai/tinyhumans/tinyhosts/Hosting"; #[tokio::main] async fn main() -> Result<(), Box> { @@ -43,12 +43,11 @@ async fn main() -> Result<(), Box> { .await??; let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; - let greeting: String = proxy.call("Greet", ("TinyBus",)).await?; - if greeting != "Hello, TinyBus!" { - return Err(io::Error::other(format!( - "module returned an unexpected greeting: {greeting}" - )) - .into()); + let providers: String = proxy.call("Providers", ()).await?; + if !providers.contains("vercel") { + return Err( + io::Error::other(format!("module reported unexpected providers: {providers}")).into(), + ); } println!( diff --git a/src/bundle/mod.rs b/src/bundle/mod.rs new file mode 100644 index 0000000..f5935c9 --- /dev/null +++ b/src/bundle/mod.rs @@ -0,0 +1,279 @@ +//! The files that make up a deployment. +//! +//! A [`Bundle`] is the source of a Next.js application as the provider will +//! receive it: relative paths, slash separated, with contents in memory. Every +//! provider this crate targets builds from source rather than from a pre-built +//! artifact, so the bundle is what a repository looks like — `package.json`, +//! `next.config.js`, `app/`, `public/` — and not a `.next` directory. +//! +//! [`Bundle::from_dir`] therefore skips what a build produces or a package +//! manager fetches: see [`EXCLUDED`]. Uploading `node_modules` is the mistake +//! this list exists to prevent, and it is not a small one — it is usually +//! several hundred megabytes of files the builder is about to fetch itself. +//! +//! Contents cross a process boundary as base64. A JSON array of byte-integers +//! costs roughly three characters per byte, and a bundle is the largest payload +//! this crate moves. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::{Error, Result}; + +/// Directory and file names [`Bundle::from_dir`] never descends into or reads. +/// +/// Every entry is either a build output, a dependency cache, or version-control +/// metadata: reproducible from the sources beside it, and never part of what a +/// provider needs to build. +pub const EXCLUDED: &[&str] = &[ + "node_modules", + ".git", + ".next", + ".turbo", + ".vercel", + ".netlify", + ".wrangler", + ".svelte-kit", + "out", + "coverage", + ".DS_Store", + ".env", + ".env.local", +]; + +/// One file in a deployment. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SiteFile { + /// The path the file takes inside the deployment, relative and slash + /// separated. + pub path: String, + /// The file's bytes, carried as base64 in serialized form. + #[serde(with = "base64_bytes")] + pub contents: Vec, +} + +impl SiteFile { + /// A file at `path` holding `contents`. + /// + /// Backslashes in `path` become slashes, so a bundle built on Windows + /// deploys the same tree as one built anywhere else. + /// + /// # Errors + /// + /// Returns [`Error::InvalidBundlePath`] when the path is blank, absolute, or + /// climbs out of the bundle with `..`. + pub fn new(path: impl Into, contents: impl Into>) -> Result { + let supplied = path.into(); + let normalized = supplied.replace('\\', "/"); + let trimmed = normalized.trim().trim_start_matches("./"); + + let rejected = trimmed.is_empty() + || trimmed.starts_with('/') + || trimmed.split('/').any(|segment| segment == ".."); + if rejected { + return Err(Error::InvalidBundlePath { path: supplied }); + } + + Ok(Self { + path: trimmed.to_owned(), + contents: contents.into(), + }) + } + + /// The file's size in bytes. + #[must_use] + pub fn len(&self) -> usize { + self.contents.len() + } + + /// Whether the file is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.contents.is_empty() + } +} + +/// Prints the path and the size, never the bytes. +/// +/// A derived `Debug` on a bundle would render an entire application into a log +/// line, and any `.env`-shaped file inside it. +impl std::fmt::Debug for SiteFile { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SiteFile") + .field("path", &self.path) + .field("bytes", &self.contents.len()) + .finish() + } +} + +/// The files of one deployment. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "Vec", into = "Vec")] +pub struct Bundle { + files: Vec, +} + +impl Bundle { + /// An empty bundle. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// A bundle holding exactly `files`. + #[must_use] + pub fn from_files(files: Vec) -> Self { + Self { files } + } + + /// Adds a file, replacing any file already at that path. + /// + /// # Errors + /// + /// Returns [`Error::InvalidBundlePath`] when the path is not a relative path + /// inside the bundle. + pub fn insert(&mut self, path: impl Into, contents: impl Into>) -> Result<()> { + let file = SiteFile::new(path, contents)?; + match self.files.iter_mut().find(|held| held.path == file.path) { + Some(held) => *held = file, + None => self.files.push(file), + } + Ok(()) + } + + /// Reads a directory tree into a bundle, skipping [`EXCLUDED`] entries. + /// + /// Symbolic links are skipped rather than followed: a link is either + /// redundant with a file already in the tree or points outside it, and + /// following one is how a bundle acquires a file the caller never meant to + /// publish. + /// + /// # Errors + /// + /// Returns [`Error::ReadBundle`] when the tree cannot be walked or a file + /// cannot be read, and [`Error::EmptyBundle`] when nothing was collected. + pub fn from_dir(root: impl AsRef) -> Result { + let root = root.as_ref(); + let mut bundle = Self::new(); + collect(root, root, &mut bundle)?; + + if bundle.is_empty() { + return Err(Error::EmptyBundle); + } + Ok(bundle) + } + + /// The files, in insertion order. + #[must_use] + pub fn files(&self) -> &[SiteFile] { + &self.files + } + + /// How many files the bundle holds. + #[must_use] + pub fn len(&self) -> usize { + self.files.len() + } + + /// Whether the bundle holds no files. + #[must_use] + pub fn is_empty(&self) -> bool { + self.files.is_empty() + } + + /// The bundle's total size in bytes. + #[must_use] + pub fn total_bytes(&self) -> usize { + self.files.iter().map(SiteFile::len).sum() + } +} + +impl TryFrom> for Bundle { + type Error = Error; + + /// Revalidates every path, because a deserialized bundle did not go through + /// [`SiteFile::new`]. + fn try_from(files: Vec) -> Result { + let mut bundle = Self::new(); + for file in files { + bundle.insert(file.path, file.contents)?; + } + Ok(bundle) + } +} + +impl From for Vec { + fn from(bundle: Bundle) -> Self { + bundle.files + } +} + +/// Walks `directory`, adding every readable file below it to `bundle`. +fn collect(root: &Path, directory: &Path, bundle: &mut Bundle) -> Result<()> { + let entries = std::fs::read_dir(directory).map_err(|error| read_error(directory, &error))?; + + for entry in entries { + let entry = entry.map_err(|error| read_error(directory, &error))?; + let path = entry.path(); + + let name = entry.file_name().to_string_lossy().into_owned(); + if EXCLUDED.contains(&name.as_str()) { + continue; + } + + let file_type = entry + .file_type() + .map_err(|error| read_error(&path, &error))?; + + if file_type.is_symlink() { + continue; + } + + if file_type.is_dir() { + collect(root, &path, bundle)?; + continue; + } + + let relative = path + .strip_prefix(root) + .map_err(|error| read_error(&path, &error))?; + let contents = std::fs::read(&path).map_err(|error| read_error(&path, &error))?; + + bundle.insert(relative.to_string_lossy().into_owned(), contents)?; + } + + Ok(()) +} + +/// The failure reported when part of a directory tree cannot be read. +fn read_error(path: &Path, reason: &dyn std::fmt::Display) -> Error { + Error::ReadBundle { + path: path.display().to_string(), + reason: reason.to_string(), + } +} + +/// Serializes file contents as base64 rather than as an array of integers. +mod base64_bytes { + use base64::Engine as _; + use base64::engine::general_purpose::STANDARD; + use serde::{Deserialize, Deserializer, Serializer}; + + pub(super) fn serialize(bytes: &[u8], serializer: S) -> Result { + serializer.serialize_str(&STANDARD.encode(bytes)) + } + + pub(super) fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let encoded = String::deserialize(deserializer)?; + STANDARD + .decode(encoded.as_bytes()) + .map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod test; diff --git a/src/bundle/test.rs b/src/bundle/test.rs new file mode 100644 index 0000000..bca2119 --- /dev/null +++ b/src/bundle/test.rs @@ -0,0 +1,207 @@ +//! Unit tests for deployment bundles. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +#[test] +fn normalizes_a_relative_path() { + let file = SiteFile::new("./app/page.tsx", b"x".to_vec()).unwrap(); + + assert_eq!(file.path, "app/page.tsx"); + assert_eq!(file.len(), 1); + assert!(!file.is_empty()); +} + +#[test] +fn converts_windows_separators() { + let file = SiteFile::new(r"app\routes\page.tsx", Vec::new()).unwrap(); + + assert_eq!(file.path, "app/routes/page.tsx"); + assert!(file.is_empty()); +} + +#[test] +fn rejects_a_path_outside_the_bundle() { + for path in ["", " ", "/etc/passwd", "../secrets.env", "app/../../x"] { + let error = SiteFile::new(path, Vec::new()).unwrap_err(); + assert!( + matches!(error, Error::InvalidBundlePath { .. }), + "{path} produced {error:?}" + ); + } +} + +#[test] +fn debug_prints_the_size_and_never_the_bytes() { + let file = SiteFile::new("secret.env", b"TOKEN=hunter2".to_vec()).unwrap(); + let rendered = format!("{file:?}"); + + assert!(!rendered.contains("hunter2"), "{rendered}"); + assert!(rendered.contains("bytes: 13"), "{rendered}"); +} + +#[test] +fn insert_replaces_a_file_at_the_same_path() { + let mut bundle = Bundle::new(); + bundle.insert("package.json", b"{}".to_vec()).unwrap(); + bundle + .insert("package.json", b"{\"a\":1}".to_vec()) + .unwrap(); + + assert_eq!(bundle.len(), 1); + assert_eq!(bundle.files()[0].contents, b"{\"a\":1}"); + assert_eq!(bundle.total_bytes(), 7); +} + +#[test] +fn insert_rejects_an_invalid_path() { + let mut bundle = Bundle::new(); + + assert!(matches!( + bundle.insert("../x", Vec::new()).unwrap_err(), + Error::InvalidBundlePath { .. } + )); +} + +#[test] +fn an_empty_bundle_reports_itself_empty() { + let bundle = Bundle::default(); + + assert!(bundle.is_empty()); + assert_eq!(bundle.len(), 0); + assert_eq!(bundle.total_bytes(), 0); + assert!(bundle.files().is_empty()); +} + +#[test] +fn from_files_keeps_what_it_was_given() { + let bundle = Bundle::from_files(vec![SiteFile::new("a.txt", b"a".to_vec()).unwrap()]); + + assert_eq!(bundle.len(), 1); +} + +#[test] +fn reads_a_directory_tree_and_skips_build_output() { + let root = tempfile::tempdir().unwrap(); + let path = root.path(); + + std::fs::write(path.join("package.json"), b"{}").unwrap(); + std::fs::create_dir(path.join("app")).unwrap(); + std::fs::write(path.join("app/page.tsx"), b"page").unwrap(); + std::fs::create_dir(path.join("node_modules")).unwrap(); + std::fs::write(path.join("node_modules/huge.js"), b"nope").unwrap(); + std::fs::create_dir(path.join(".next")).unwrap(); + std::fs::write(path.join(".next/build"), b"nope").unwrap(); + std::fs::write(path.join(".env"), b"TOKEN=nope").unwrap(); + + let bundle = Bundle::from_dir(path).unwrap(); + let mut paths: Vec<&str> = bundle + .files() + .iter() + .map(|file| file.path.as_str()) + .collect(); + paths.sort_unstable(); + + assert_eq!(paths, ["app/page.tsx", "package.json"]); +} + +#[test] +fn an_empty_directory_is_not_a_deployment() { + let root = tempfile::tempdir().unwrap(); + + assert_eq!( + Bundle::from_dir(root.path()).unwrap_err(), + Error::EmptyBundle + ); +} + +#[test] +fn a_missing_directory_reports_what_could_not_be_read() { + let error = Bundle::from_dir("./does-not-exist-anywhere").unwrap_err(); + + assert!( + matches!(error, Error::ReadBundle { .. }), + "unexpected error: {error:?}" + ); +} + +#[cfg(unix)] +#[test] +fn skips_symbolic_links() { + let root = tempfile::tempdir().unwrap(); + let path = root.path(); + + std::fs::write(path.join("real.txt"), b"real").unwrap(); + std::os::unix::fs::symlink("/etc/hostname", path.join("link.txt")).unwrap(); + + let bundle = Bundle::from_dir(path).unwrap(); + + assert_eq!(bundle.len(), 1); + assert_eq!(bundle.files()[0].path, "real.txt"); +} + +#[cfg(unix)] +#[test] +fn a_directory_that_cannot_be_read_reports_which_one() { + use std::os::unix::fs::PermissionsExt as _; + + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("package.json"), b"{}").unwrap(); + let blocked = root.path().join("blocked"); + std::fs::create_dir(&blocked).unwrap(); + std::fs::write(blocked.join("inner.txt"), b"inner").unwrap(); + std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap(); + + let result = Bundle::from_dir(root.path()); + + // Root ignores the mode bits, so the unreadable case cannot be forced there. + match result { + Err(error) => assert!( + matches!(error, Error::ReadBundle { .. }), + "unexpected error: {error:?}" + ), + Ok(bundle) => assert_eq!(bundle.len(), 2, "only root can read a 0o000 directory"), + } + + // Leave the tree removable. + std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o700)).unwrap(); +} + +#[test] +fn contents_travel_as_base64() { + let mut bundle = Bundle::new(); + bundle.insert("app/page.tsx", b"hello".to_vec()).unwrap(); + + let json = serde_json::to_string(&bundle).unwrap(); + assert!(json.contains("aGVsbG8="), "{json}"); + + let restored: Bundle = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, bundle); +} + +#[test] +fn deserializing_revalidates_every_path() { + let error = + serde_json::from_str::(r#"[{"path":"../escape","contents":"aGk="}]"#).unwrap_err(); + + assert!(error.to_string().contains("not a relative path"), "{error}"); +} + +#[test] +fn deserializing_rejects_contents_that_are_not_base64() { + let error = serde_json::from_str::(r#"[{"path":"a.txt","contents":"not base64!"}]"#) + .unwrap_err(); + + assert!(!error.to_string().is_empty()); +} + +#[test] +fn a_bundle_converts_back_into_its_files() { + let mut bundle = Bundle::new(); + bundle.insert("a.txt", b"a".to_vec()).unwrap(); + + let files: Vec = bundle.into(); + + assert_eq!(files.len(), 1); +} diff --git a/src/credentials/mod.rs b/src/credentials/mod.rs new file mode 100644 index 0000000..4a493bc --- /dev/null +++ b/src/credentials/mod.rs @@ -0,0 +1,125 @@ +//! The credential a hosting provider is called with. +//! +//! A user pastes an API key into `OpenCompany`; it reaches this crate as a +//! [`Credentials`], is attached to every outbound request, and is never +//! rendered. [`Credentials`] therefore has a hand-written [`Debug`] that +//! redacts the key, and deliberately implements [`serde::Deserialize`] without +//! [`serde::Serialize`] — a value can be read out of a request envelope, and +//! cannot be written back into a response, a log line, or a ledger. +//! +//! Which environment variables hold a key is a per-provider fact, so reading +//! one from the environment belongs to +//! [`ProviderKind`](crate::ProviderKind::credentials_from_env) rather than here. + +use std::fmt; + +use crate::{Error, Result}; + +/// An API key for one hosting provider account, and the team to act as. +/// +/// # Examples +/// +/// ``` +/// # use tinyhosts::Credentials; +/// let credentials = Credentials::new("vercel-token")?.with_team("team_abc"); +/// +/// assert_eq!(credentials.api_key(), "vercel-token"); +/// assert_eq!(credentials.team(), Some("team_abc")); +/// // The key never reaches a rendered string. +/// assert!(!format!("{credentials:?}").contains("vercel-token")); +/// # Ok::<(), tinyhosts::Error>(()) +/// ``` +#[derive(Clone, PartialEq, Eq, serde::Deserialize)] +#[serde(try_from = "Wire")] +pub struct Credentials { + api_key: String, + team: Option, +} + +impl Credentials { + /// Builds a credential from an API key, trimming surrounding whitespace. + /// + /// # Errors + /// + /// Returns [`Error::EmptyApiKey`] when `api_key` is empty or contains only + /// whitespace. + pub fn new(api_key: impl Into) -> Result { + let api_key = api_key.into().trim().to_owned(); + if api_key.is_empty() { + return Err(Error::EmptyApiKey); + } + + Ok(Self { + api_key, + team: None, + }) + } + + /// Acts on behalf of a team, organization, or account scope. + /// + /// A blank team is the same as none, so a form field left empty does not + /// become a query parameter the provider rejects. + #[must_use] + pub fn with_team(mut self, team: impl Into) -> Self { + let team = team.into().trim().to_owned(); + self.team = if team.is_empty() { None } else { Some(team) }; + self + } + + /// The API key, for the provider client that signs a request with it. + #[must_use] + pub fn api_key(&self) -> &str { + &self.api_key + } + + /// The team scope, when one was supplied. + #[must_use] + pub fn team(&self) -> Option<&str> { + self.team.as_deref() + } + + /// Consumes the credential into its API key and team. + /// + /// A provider client holds the key for its whole life, so it takes + /// ownership rather than copying out of a borrow it would have to keep. + #[must_use] + pub fn into_parts(self) -> (String, Option) { + (self.api_key, self.team) + } +} + +/// Redacts the key. Everything that prints a credential goes through here. +impl fmt::Debug for Credentials { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Credentials") + .field("api_key", &"") + .field("team", &self.team) + .finish() + } +} + +/// The deserialized form, so a value read from JSON is validated exactly like +/// one built through [`Credentials::new`]. +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct Wire { + api_key: String, + #[serde(default)] + team: Option, +} + +impl TryFrom for Credentials { + type Error = Error; + + fn try_from(wire: Wire) -> Result { + let credentials = Self::new(wire.api_key)?; + Ok(match wire.team { + Some(team) => credentials.with_team(team), + None => credentials, + }) + } +} + +#[cfg(test)] +mod test; diff --git a/src/credentials/test.rs b/src/credentials/test.rs new file mode 100644 index 0000000..c3ad936 --- /dev/null +++ b/src/credentials/test.rs @@ -0,0 +1,76 @@ +//! Unit tests for credential handling and redaction. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +#[test] +fn trims_the_api_key() { + let credentials = Credentials::new(" token ").unwrap(); + + assert_eq!(credentials.api_key(), "token"); + assert_eq!(credentials.team(), None); +} + +#[test] +fn rejects_an_empty_api_key() { + assert_eq!(Credentials::new("").unwrap_err(), Error::EmptyApiKey); + assert_eq!(Credentials::new(" \t\n ").unwrap_err(), Error::EmptyApiKey); +} + +#[test] +fn a_blank_team_is_no_team() { + let credentials = Credentials::new("token").unwrap().with_team(" "); + + assert_eq!(credentials.team(), None); +} + +#[test] +fn trims_the_team() { + let credentials = Credentials::new("token").unwrap().with_team(" team_abc "); + + assert_eq!(credentials.team(), Some("team_abc")); +} + +#[test] +fn debug_redacts_the_api_key() { + let rendered = format!("{:?}", Credentials::new("super-secret").unwrap()); + + assert!(!rendered.contains("super-secret"), "{rendered}"); + assert!(rendered.contains(""), "{rendered}"); +} + +#[test] +fn deserializes_from_a_request_envelope() { + let credentials: Credentials = + serde_json::from_str(r#"{"api_key":" token ","team":"team_abc"}"#).unwrap(); + + assert_eq!(credentials.api_key(), "token"); + assert_eq!(credentials.team(), Some("team_abc")); +} + +#[test] +fn deserializes_without_a_team() { + let credentials: Credentials = serde_json::from_str(r#"{"api_key":"token"}"#).unwrap(); + + assert_eq!(credentials.team(), None); +} + +#[test] +fn deserializing_an_empty_api_key_fails() { + let error = serde_json::from_str::(r#"{"api_key":" "}"#).unwrap_err(); + + assert!(error.to_string().contains("api key must not be empty")); +} + +#[test] +fn credentials_compare_by_value() { + assert_eq!( + Credentials::new("token").unwrap(), + Credentials::new("token").unwrap() + ); + assert_ne!( + Credentials::new("token").unwrap(), + Credentials::new("other").unwrap() + ); +} diff --git a/src/error/mod.rs b/src/error/mod.rs index b8ddbe0..e625f8f 100644 --- a/src/error/mod.rs +++ b/src/error/mod.rs @@ -8,14 +8,182 @@ //! Variants carry the data a caller needs to react, keep their `#[error]` //! message lowercase and free of trailing punctuation, and are documented so //! the rendered rustdoc explains when each one occurs. +//! +//! Provider failures name the provider they came from. A run may talk to more +//! than one host in the same process, and "unauthorized" is not actionable +//! until the reader knows which credential was rejected. /// Errors returned by this crate. #[derive(Debug, thiserror::Error, PartialEq, Eq)] #[non_exhaustive] pub enum Error { - /// A required name was empty or contained only whitespace. - #[error("name must not be empty")] - EmptyName, + /// An API key was empty or contained only whitespace. + #[error("api key must not be empty")] + EmptyApiKey, + + /// No API key was found in the environment for a provider. + /// + /// `variables` lists every name that was searched, in order. + #[error("no api key for {provider}: set one of {variables}")] + MissingApiKey { + /// The provider whose credential is missing. + provider: String, + /// The environment variable names that were searched, comma separated. + variables: String, + }, + + /// A site name was empty or contained only whitespace. + #[error("site name must not be empty")] + EmptySiteName, + + /// A deployment was requested with no files in it. + #[error("deployment bundle contains no files")] + EmptyBundle, + + /// A bundle entry named a path that cannot be deployed. + /// + /// Bundle paths are relative, slash separated, and may not traverse out of + /// the bundle root. + #[error("bundle path {path} is not a relative path inside the bundle")] + InvalidBundlePath { + /// The rejected path, as it was supplied. + path: String, + }, + + /// The filesystem refused a read while building a bundle from a directory. + #[error("cannot read {path}: {reason}")] + ReadBundle { + /// The path that could not be read. + path: String, + /// The operating system's reason. + reason: String, + }, + + /// An environment variable name was empty or contained only whitespace. + #[error("environment variable name must not be empty")] + EmptyEnvKey, + + /// A domain name was empty or contained only whitespace. + #[error("domain name must not be empty")] + EmptyDomain, + + /// An analytics window ended at or before it started. + #[error("analytics window must end after it starts")] + InvalidAnalyticsWindow, + + /// The request never reached the provider, or its response never arrived. + #[error("request to {provider} failed: {reason}")] + Transport { + /// The provider that was being called. + provider: String, + /// The transport-level reason. + reason: String, + }, + + /// The provider rejected the credential. + #[error("{provider} rejected the api key")] + Unauthorized { + /// The provider that rejected it. + provider: String, + }, + + /// The credential is valid but not permitted to touch the resource. + #[error("{provider} denied access to {resource}")] + Forbidden { + /// The provider that denied the request. + provider: String, + /// The resource that was denied, as this crate named it. + resource: String, + }, + + /// The provider has no such resource. + #[error("{resource} not found on {provider}")] + NotFound { + /// The provider that was queried. + provider: String, + /// The resource that was missing. + resource: String, + }, + + /// The provider is throttling this credential. + #[error("{provider} rate limited the request")] + RateLimited { + /// The provider that throttled the request. + provider: String, + }, + + /// The provider returned a failure this crate has no specific variant for. + #[error("{provider} returned {status} for {resource}: {message}")] + Api { + /// The provider that failed. + provider: String, + /// The HTTP status code. + status: u16, + /// The resource that was being fetched, as this crate named it. + resource: String, + /// The provider's own message, or its status text. + message: String, + }, + + /// A provider response did not match the shape this crate expects. + #[error("cannot decode the {provider} response for {resource}: {reason}")] + Decode { + /// The provider whose response could not be read. + provider: String, + /// The resource that was being fetched. + resource: String, + /// The deserialization error. + reason: String, + }, + + /// A provider was named that this build does not have. + /// + /// Either the name is not a provider, or its Cargo feature is off. + #[error("unknown hosting provider {name}")] + UnknownProvider { + /// The name that was supplied. + name: String, + }, + + /// The provider cannot do something the unified API exposes. + /// + /// Not every host has every capability, and a stub that silently succeeds + /// is worse than a refusal that names what is missing. + #[error("{provider} cannot {capability}")] + Unsupported { + /// The provider that lacks the capability. + provider: String, + /// The capability, phrased to complete the sentence "cannot ...". + capability: String, + }, + + /// No installed integration on the account can provision this database. + /// + /// On Vercel a managed database comes from a marketplace integration, so + /// one has to be installed on the account before a store can be created. + #[error("no installed {provider} integration provides a {kind} database")] + NoDatabaseProduct { + /// The provider that was searched. + provider: String, + /// The requested database kind. + kind: String, + }, + + /// A database was created but did not come up. + #[error("database {name} was provisioned but reported status {status}")] + DatabaseNotReady { + /// The database's name. + name: String, + /// The status the provider reported. + status: String, + }, + + /// A JSON envelope crossing a process boundary could not be read. + #[error("cannot decode the request envelope: {reason}")] + Envelope { + /// The deserialization error. + reason: String, + }, } /// The crate's standard result type. diff --git a/src/error/test.rs b/src/error/test.rs index 4c5d609..4c4b7a5 100644 --- a/src/error/test.rs +++ b/src/error/test.rs @@ -6,12 +6,53 @@ use super::*; #[test] fn renders_a_human_readable_message() { - assert_eq!(Error::EmptyName.to_string(), "name must not be empty"); + assert_eq!(Error::EmptyApiKey.to_string(), "api key must not be empty"); +} + +#[test] +fn a_missing_key_names_every_variable_it_searched() { + let error = Error::MissingApiKey { + provider: "vercel".to_owned(), + variables: "TINYHOSTS_VERCEL_TOKEN, VERCEL_TOKEN".to_owned(), + }; + + assert_eq!( + error.to_string(), + "no api key for vercel: set one of TINYHOSTS_VERCEL_TOKEN, VERCEL_TOKEN" + ); +} + +#[test] +fn a_provider_failure_names_the_provider_and_the_resource() { + let error = Error::Api { + provider: "vercel".to_owned(), + status: 500, + resource: "deployment".to_owned(), + message: "internal".to_owned(), + }; + + assert_eq!( + error.to_string(), + "vercel returned 500 for deployment: internal" + ); +} + +#[test] +fn an_unsupported_capability_completes_the_sentence() { + let error = Error::Unsupported { + provider: "vercel".to_owned(), + capability: "provision a mysql database".to_owned(), + }; + + assert_eq!( + error.to_string(), + "vercel cannot provision a mysql database" + ); } #[test] fn is_a_standard_error() { fn assert_error(_: &E) {} - assert_error(&Error::EmptyName); + assert_error(&Error::EmptyBundle); } diff --git a/src/greeting/mod.rs b/src/greeting/mod.rs deleted file mode 100644 index 5b4ad65..0000000 --- a/src/greeting/mod.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Greeting behavior used to demonstrate the template's module layout. -//! -//! A module root like this one documents the module, wires its pieces -//! together, and exposes the smallest useful API. Substantial type definitions -//! belong in a sibling `types.rs`, and unit tests belong in `test.rs`, wired in -//! at the bottom of this file. -//! -//! Replace this module with the crate's first real feature area. - -use crate::{Error, Result}; - -/// Returns a friendly greeting for `name`. -/// -/// Surrounding whitespace is trimmed before the greeting is built. -/// -/// # Examples -/// -/// ``` -/// # use rust_template::greet; -/// assert_eq!(greet(" Ferris ")?, "Hello, Ferris!"); -/// # Ok::<(), rust_template::Error>(()) -/// ``` -/// -/// # Errors -/// -/// Returns [`Error::EmptyName`] when `name` is empty or contains only -/// whitespace. -pub fn greet(name: &str) -> Result { - let name = name.trim(); - if name.is_empty() { - return Err(Error::EmptyName); - } - - Ok(format!("Hello, {name}!")) -} - -#[cfg(test)] -mod test; diff --git a/src/greeting/test.rs b/src/greeting/test.rs deleted file mode 100644 index de04ef4..0000000 --- a/src/greeting/test.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! Unit tests for the greeting module. -//! -//! Unit tests live next to the code they cover and may reach into private -//! items. Tests of the public contract belong in `tests/` instead. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use super::*; - -#[test] -fn greets_a_named_person() { - assert_eq!(greet("Ferris").unwrap(), "Hello, Ferris!"); -} - -#[test] -fn trims_the_name() { - assert_eq!(greet(" Ferris ").unwrap(), "Hello, Ferris!"); -} - -#[test] -fn rejects_an_empty_name() { - assert_eq!(greet("").unwrap_err(), Error::EmptyName); -} - -#[test] -fn rejects_a_whitespace_only_name() { - assert_eq!(greet(" \t\n ").unwrap_err(), Error::EmptyName); -} diff --git a/src/host/mod.rs b/src/host/mod.rs new file mode 100644 index 0000000..c13952c --- /dev/null +++ b/src/host/mod.rs @@ -0,0 +1,178 @@ +//! The unified hosting interface. +//! +//! [`Host`] is the whole contract a provider adapter implements, and the whole +//! surface a caller needs. It is deliberately narrow: it covers shipping a +//! Next.js application and keeping it running — the site, its deployments, the +//! environment it reads, a database behind it, a domain in front of it, and the +//! traffic it served — and nothing else. Everything a provider offers beyond +//! that is reached through the provider's own client, not through here. +//! +//! Methods are grouped in the order a launch uses them, which is also the order +//! [`launch`](crate::launch()) calls them in. +//! +//! # Not every host does everything +//! +//! A capability a provider lacks returns [`Error::Unsupported`], naming the +//! provider and what it cannot do. That is the only honest answer: a stub that +//! returns `Ok` for a database it never created would be discovered at runtime +//! by an application whose `DATABASE_URL` is missing. +//! +//! [`Error::Unsupported`]: crate::Error::Unsupported + +use async_trait::async_trait; + +use crate::Result; +use crate::host::types::{ + AnalyticsQuery, AnalyticsSummary, Database, DatabaseSpec, DeployRequest, Deployment, Domain, + EnvVar, EnvVarRecord, Site, SiteSpec, +}; +use crate::providers::ProviderKind; + +pub mod types; + +/// One hosting provider account, reached through the unified model. +/// +/// Implementations are cheap to clone and safe to share: each one is an HTTP +/// client plus a credential. Every method performs network I/O. +#[async_trait] +pub trait Host: Send + Sync + std::fmt::Debug { + /// Which provider this is. + fn kind(&self) -> ProviderKind; + + /// Creates a site. + /// + /// # Errors + /// + /// Returns [`Error::EmptySiteName`](crate::Error::EmptySiteName) for a blank + /// name, or a provider error — including one for a name already taken. + async fn create_site(&self, spec: &SiteSpec) -> Result; + + /// Finds a site by name or identifier, returning `None` if there is none. + /// + /// # Errors + /// + /// Returns a provider error. A missing site is `Ok(None)`, not an error: + /// "create it if it is not there" is the common path, and it should not be + /// written by catching a failure. + async fn find_site(&self, name: &str) -> Result>; + + /// Lists sites, newest first, capped at `limit`. + /// + /// # Errors + /// + /// Returns a provider error. + async fn list_sites(&self, limit: u32) -> Result>; + + /// Sets environment variables on a site, replacing any of the same name. + /// + /// Variables must be in place before the deployment that reads them: a + /// Next.js build inlines what it can see at build time. + /// + /// # Errors + /// + /// Returns [`Error::EmptyEnvKey`](crate::Error::EmptyEnvKey) for a blank + /// name, or a provider error. + async fn set_env(&self, site: &str, vars: &[EnvVar]) -> Result<()>; + + /// Lists the environment variables on a site, without their values. + /// + /// # Errors + /// + /// Returns a provider error. + async fn list_env(&self, site: &str) -> Result>; + + /// Provisions a managed database on the account. + /// + /// The database is not reachable from a site until + /// [`attach_database`](Host::attach_database) connects the two. + /// + /// # Errors + /// + /// Returns [`Error::NoDatabaseProduct`](crate::Error::NoDatabaseProduct) + /// when nothing on the account can serve the requested kind, + /// [`Error::Unsupported`](crate::Error::Unsupported) when the provider has + /// no managed databases at all, or a provider error. + async fn provision_database(&self, spec: &DatabaseSpec) -> Result; + + /// Connects a database to a site and returns the variable names the site + /// now receives. + /// + /// The values are the provider's to inject. This crate does not see a + /// connection string, which is why the return is a list of names. + /// + /// # Errors + /// + /// Returns [`Error::Unsupported`](crate::Error::Unsupported) when the + /// provider cannot connect a database to a site, or a provider error. + async fn attach_database(&self, database: &Database, site: &str) -> Result>; + + /// Uploads a bundle and starts a deployment. + /// + /// The returned deployment has usually not finished building. Poll + /// [`deployment`](Host::deployment) until + /// [`DeploymentStatus::is_terminal`](types::DeploymentStatus::is_terminal). + /// + /// # Errors + /// + /// Returns [`Error::EmptyBundle`](crate::Error::EmptyBundle) for an empty + /// bundle, [`Error::EmptySiteName`](crate::Error::EmptySiteName) for a blank + /// site, or a provider error. + async fn deploy(&self, request: &DeployRequest) -> Result; + + /// Reads a deployment's current state. + /// + /// # Errors + /// + /// Returns a provider error, including + /// [`Error::NotFound`](crate::Error::NotFound) for an unknown identifier. + async fn deployment(&self, id: &str) -> Result; + + /// Lists a site's deployments, newest first, capped at `limit`. + /// + /// # Errors + /// + /// Returns a provider error. + async fn list_deployments(&self, site: &str, limit: u32) -> Result>; + + /// Points the site's production traffic at an existing deployment. + /// + /// This is both the promote and the rollback: a rollback is a promote of an + /// older deployment, and modelling it twice would suggest otherwise. + /// + /// # Errors + /// + /// Returns [`Error::Unsupported`](crate::Error::Unsupported) when the + /// provider cannot repoint traffic without rebuilding, or a provider error. + async fn promote(&self, site: &str, deployment: &str) -> Result<()>; + + /// Adds a custom domain to a site. + /// + /// A returned domain with `verified` false still needs its DNS records; the + /// provider, not this crate, is the source of truth for what they are. + /// + /// # Errors + /// + /// Returns [`Error::EmptyDomain`](crate::Error::EmptyDomain) for a blank + /// name, or a provider error. + async fn add_domain(&self, site: &str, domain: &str) -> Result; + + /// Lists a site's domains. + /// + /// # Errors + /// + /// Returns a provider error. + async fn list_domains(&self, site: &str) -> Result>; + + /// Reports the traffic a site served over a window. + /// + /// # Errors + /// + /// Returns [`Error::InvalidAnalyticsWindow`](crate::Error::InvalidAnalyticsWindow) + /// for a window that does not move forward, + /// [`Error::Unsupported`](crate::Error::Unsupported) when the provider has + /// no analytics, or a provider error. + async fn analytics(&self, query: &AnalyticsQuery) -> Result; +} + +#[cfg(test)] +mod test; diff --git a/src/host/test.rs b/src/host/test.rs new file mode 100644 index 0000000..16925ef --- /dev/null +++ b/src/host/test.rs @@ -0,0 +1,249 @@ +//! Unit tests for the unified model's vocabulary. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use crate::Error; +use crate::bundle::Bundle; +use crate::host::types::{ + AnalyticsDimension, AnalyticsQuery, DatabaseKind, DatabaseSpec, DeployRequest, Deployment, + DeploymentStatus, DeploymentTarget, EnvVar, Framework, SiteSpec, +}; + +fn bundle() -> Bundle { + let mut bundle = Bundle::new(); + bundle.insert("package.json", b"{}".to_vec()).unwrap(); + bundle +} + +#[test] +fn a_site_defaults_to_next_js() { + let spec = SiteSpec::new("shop"); + + assert_eq!(spec.framework, Framework::NextJs); + assert_eq!(spec.framework.as_str(), "nextjs"); + assert!(spec.validate().is_ok()); +} + +#[test] +fn a_site_can_be_something_else() { + let spec = SiteSpec::new("docs").with_framework(Framework::Other("astro".to_owned())); + + assert_eq!(spec.framework.as_str(), "astro"); + assert_eq!(Framework::Static.as_str(), "static"); + assert_eq!(Framework::default(), Framework::NextJs); +} + +#[test] +fn a_blank_site_name_is_rejected() { + assert_eq!( + SiteSpec::new(" ").validate().unwrap_err(), + Error::EmptySiteName + ); +} + +#[test] +fn a_target_names_itself() { + assert_eq!(DeploymentTarget::Preview.as_str(), "preview"); + assert_eq!(DeploymentTarget::Production.as_str(), "production"); + assert_eq!(DeploymentTarget::default(), DeploymentTarget::Preview); +} + +#[test] +fn a_deploy_request_defaults_to_a_next_js_preview() { + let request = DeployRequest::new("shop", bundle()); + + assert_eq!(request.target, DeploymentTarget::Preview); + assert_eq!(request.framework, Framework::NextJs); + assert!(request.validate().is_ok()); +} + +#[test] +fn a_deploy_request_can_be_retargeted() { + let request = DeployRequest::new("shop", bundle()) + .with_target(DeploymentTarget::Production) + .with_framework(Framework::Static); + + assert_eq!(request.target, DeploymentTarget::Production); + assert_eq!(request.framework, Framework::Static); +} + +#[test] +fn a_deploy_request_needs_a_site_and_files() { + assert_eq!( + DeployRequest::new(" ", bundle()).validate().unwrap_err(), + Error::EmptySiteName + ); + assert_eq!( + DeployRequest::new("shop", Bundle::new()) + .validate() + .unwrap_err(), + Error::EmptyBundle + ); +} + +#[test] +fn only_a_settled_status_is_terminal() { + for status in [ + DeploymentStatus::Ready, + DeploymentStatus::Failed, + DeploymentStatus::Canceled, + ] { + assert!(status.is_terminal(), "{status:?}"); + } + + for status in [ + DeploymentStatus::Queued, + DeploymentStatus::Building, + DeploymentStatus::Other("BLOCKED".to_owned()), + ] { + assert!(!status.is_terminal(), "{status:?}"); + } + + assert!(DeploymentStatus::Ready.is_ready()); + assert!(!DeploymentStatus::Building.is_ready()); +} + +#[test] +fn an_env_var_applies_everywhere_by_default() { + let var = EnvVar::new("DATABASE_URL", "postgres://"); + + assert!(var.targets.is_empty()); + assert!(!var.secret); + assert!(var.validate().is_ok()); +} + +#[test] +fn an_env_var_can_be_scoped_and_hidden() { + let var = EnvVar::new("STRIPE_KEY", "sk_live") + .with_targets(vec![DeploymentTarget::Production]) + .secret(); + + assert_eq!(var.targets, vec![DeploymentTarget::Production]); + assert!(var.secret); +} + +#[test] +fn a_blank_env_key_is_rejected() { + assert_eq!( + EnvVar::new(" ", "value").validate().unwrap_err(), + Error::EmptyEnvKey + ); +} + +#[test] +fn a_database_defaults_to_postgres() { + let spec = DatabaseSpec::new("shop-db"); + + assert_eq!(spec.kind, DatabaseKind::Postgres); + assert_eq!(spec.product, None); + assert!(spec.validate().is_ok()); +} + +#[test] +fn a_database_can_be_pinned_to_a_product() { + let spec = DatabaseSpec::new("cache") + .with_kind(DatabaseKind::Redis) + .with_product("upstash-kv"); + + assert_eq!(spec.kind, DatabaseKind::Redis); + assert_eq!(spec.product.as_deref(), Some("upstash-kv")); +} + +#[test] +fn a_blank_database_name_is_rejected() { + assert_eq!( + DatabaseSpec::new(" ").validate().unwrap_err(), + Error::EmptySiteName + ); +} + +#[test] +fn a_database_kind_knows_the_names_vendors_use() { + assert_eq!(DatabaseKind::Postgres.as_str(), "postgres"); + assert_eq!(DatabaseKind::Redis.as_str(), "redis"); + assert_eq!(DatabaseKind::Blob.as_str(), "blob"); + assert_eq!(DatabaseKind::Other("mongo".to_owned()).as_str(), "mongo"); + + assert!(DatabaseKind::Postgres.product_hints().contains(&"neon")); + assert!(DatabaseKind::Redis.product_hints().contains(&"upstash")); + assert!(DatabaseKind::Blob.product_hints().contains(&"blob")); + assert_eq!( + DatabaseKind::Other("mongo".to_owned()).product_hints(), + vec!["mongo"] + ); +} + +#[test] +fn an_analytics_query_defaults_to_ten_rows_and_no_breakdown() { + let query = AnalyticsQuery::new("shop", 10, 20); + + assert_eq!(query.limit, 10); + assert_eq!(query.breakdown, None); + assert!(query.validate().is_ok()); +} + +#[test] +fn an_analytics_query_can_break_down_and_cap() { + let query = AnalyticsQuery::new("shop", 10, 20) + .with_breakdown(AnalyticsDimension::Country) + .with_limit(50); + + assert_eq!(query.breakdown, Some(AnalyticsDimension::Country)); + assert_eq!(query.limit, 50); +} + +#[test] +fn an_analytics_window_must_move_forward() { + assert_eq!( + AnalyticsQuery::new("shop", 20, 20).validate().unwrap_err(), + Error::InvalidAnalyticsWindow + ); + assert_eq!( + AnalyticsQuery::new(" ", 10, 20).validate().unwrap_err(), + Error::EmptySiteName + ); +} + +#[test] +fn every_dimension_has_the_providers_spelling() { + assert_eq!(AnalyticsDimension::Country.as_str(), "country"); + assert_eq!(AnalyticsDimension::DeviceType.as_str(), "deviceType"); + assert_eq!(AnalyticsDimension::RequestPath.as_str(), "requestPath"); + assert_eq!( + AnalyticsDimension::ReferrerHostname.as_str(), + "referrerHostname" + ); + assert_eq!(AnalyticsDimension::BrowserName.as_str(), "browserName"); + assert_eq!(AnalyticsDimension::OsName.as_str(), "osName"); + assert_eq!(AnalyticsDimension::Route.as_str(), "route"); +} + +#[test] +fn a_deployment_round_trips_through_json() { + let deployment = Deployment { + id: "dpl_1".to_owned(), + site: "shop".to_owned(), + url: Some("https://shop.vercel.app".to_owned()), + status: DeploymentStatus::Building, + target: DeploymentTarget::Production, + created_at_ms: Some(1), + error_message: None, + }; + + let json = serde_json::to_string(&deployment).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + deployment + ); +} + +#[test] +fn a_status_this_crate_does_not_model_survives_a_round_trip() { + let status = DeploymentStatus::Other("BLOCKED".to_owned()); + let json = serde_json::to_string(&status).unwrap(); + + assert_eq!( + serde_json::from_str::(&json).unwrap(), + status + ); +} diff --git a/src/host/types.rs b/src/host/types.rs new file mode 100644 index 0000000..676e736 --- /dev/null +++ b/src/host/types.rs @@ -0,0 +1,612 @@ +//! The provider-agnostic vocabulary every host is described in. +//! +//! These types are the standard: a site, a deployment of it, the environment it +//! reads, a managed database, a custom domain, and the traffic it served. A +//! provider adapter's whole job is translating its own API into them, so a +//! caller that can ship a Next.js application to one host can ship it to the +//! next without learning a second vocabulary. +//! +//! Records the provider produced ([`Site`], [`Deployment`], [`Database`]) carry +//! public fields and no invariants — they are whatever the provider said. +//! Requests the caller produces ([`SiteSpec`], [`DeployRequest`], [`EnvVar`], +//! [`AnalyticsQuery`]) carry a [`validate`](SiteSpec::validate) that the +//! adapter calls before spending a network round trip. Validation lives at the +//! point of use rather than in a constructor because every one of these types +//! also arrives by deserialization, where a constructor cannot intercept it. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::bundle::Bundle; +use crate::{Error, Result}; + +/// The framework a site is built with. +/// +/// The framework decides the build, so it is part of the site rather than of a +/// single deployment. [`Framework::NextJs`] is the default because it is what +/// this crate was built to ship. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum Framework { + /// A Next.js application, built by the provider from its source. + #[default] + NextJs, + /// Pre-built static files, served as they are. + Static, + /// Anything else, named the way the provider names it. + Other(String), +} + +impl Framework { + /// The provider-independent slug for this framework. + /// + /// It happens to match Vercel's `framework` values for the two named + /// variants, which is why [`Framework::Other`] passes through untouched. + #[must_use] + pub fn as_str(&self) -> &str { + match self { + Self::NextJs => "nextjs", + Self::Static => "static", + Self::Other(name) => name, + } + } +} + +/// Which environment a deployment serves. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DeploymentTarget { + /// A preview URL, not attached to the site's domains. + #[default] + Preview, + /// The live site, attached to every domain it has. + Production, +} + +impl DeploymentTarget { + /// The provider-independent slug for this target. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Preview => "preview", + Self::Production => "production", + } + } +} + +/// What the caller wants a site to be. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SiteSpec { + /// The site's name, unique within the account. + pub name: String, + /// The framework the provider should build it with. + #[serde(default)] + pub framework: Framework, +} + +impl SiteSpec { + /// A Next.js site called `name`. + #[must_use] + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + framework: Framework::NextJs, + } + } + + /// Builds the site with a different framework. + #[must_use] + pub fn with_framework(mut self, framework: Framework) -> Self { + self.framework = framework; + self + } + + /// Checks the spec before an adapter spends a network round trip on it. + /// + /// # Errors + /// + /// Returns [`Error::EmptySiteName`] when the name is blank. + pub fn validate(&self) -> Result<()> { + if self.name.trim().is_empty() { + return Err(Error::EmptySiteName); + } + Ok(()) + } +} + +/// A site that exists on a provider. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Site { + /// The provider's identifier for it. + pub id: String, + /// Its name, which is what the deployment API keys on. + pub name: String, + /// The framework the provider believes it is built with, when it says. + #[serde(default)] + pub framework: Option, + /// When it was created, in milliseconds since the Unix epoch. + #[serde(default)] + pub created_at_ms: Option, +} + +/// A request to deploy a bundle of files as a site. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeployRequest { + /// The site to deploy to, by name. + pub site: String, + /// The framework to build with. + #[serde(default)] + pub framework: Framework, + /// The environment this deployment serves. + #[serde(default)] + pub target: DeploymentTarget, + /// The files to deploy. + pub bundle: Bundle, +} + +impl DeployRequest { + /// A preview deployment of `bundle` to the Next.js site `site`. + #[must_use] + pub fn new(site: impl Into, bundle: Bundle) -> Self { + Self { + site: site.into(), + framework: Framework::NextJs, + target: DeploymentTarget::Preview, + bundle, + } + } + + /// Sends the deployment to `target` instead of a preview URL. + #[must_use] + pub fn with_target(mut self, target: DeploymentTarget) -> Self { + self.target = target; + self + } + + /// Builds with a different framework. + #[must_use] + pub fn with_framework(mut self, framework: Framework) -> Self { + self.framework = framework; + self + } + + /// Checks the request before an adapter starts uploading files. + /// + /// # Errors + /// + /// Returns [`Error::EmptySiteName`] when the site name is blank, or + /// [`Error::EmptyBundle`] when there is nothing to deploy. + pub fn validate(&self) -> Result<()> { + if self.site.trim().is_empty() { + return Err(Error::EmptySiteName); + } + if self.bundle.is_empty() { + return Err(Error::EmptyBundle); + } + Ok(()) + } +} + +/// How far along a deployment is. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum DeploymentStatus { + /// Accepted, not started. + Queued, + /// Building. + Building, + /// Live and serving. + Ready, + /// The build or the upload failed. + Failed, + /// Cancelled before it finished. + Canceled, + /// A provider state this crate does not model, named as the provider named + /// it. Reported rather than mapped onto a state it may not mean. + Other(String), +} + +impl DeploymentStatus { + /// Whether the deployment has stopped changing. + /// + /// A poller stops here. [`DeploymentStatus::Other`] counts as non-terminal: + /// an unknown state is more likely a stage of the build than the end of it, + /// and a poller that gives up early reports a live site as a failure. + #[must_use] + pub const fn is_terminal(&self) -> bool { + matches!(self, Self::Ready | Self::Failed | Self::Canceled) + } + + /// Whether the deployment finished and is serving traffic. + #[must_use] + pub const fn is_ready(&self) -> bool { + matches!(self, Self::Ready) + } +} + +/// A deployment of a site. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Deployment { + /// The provider's identifier, which is what a status poll asks about. + pub id: String, + /// The site it belongs to, by name. + pub site: String, + /// Where it is served, as an absolute URL, once the provider assigns one. + #[serde(default)] + pub url: Option, + /// How far along it is. + pub status: DeploymentStatus, + /// Which environment it serves. + #[serde(default)] + pub target: DeploymentTarget, + /// When it was created, in milliseconds since the Unix epoch. + #[serde(default)] + pub created_at_ms: Option, + /// The provider's failure message, when it failed. + #[serde(default)] + pub error_message: Option, +} + +/// An environment variable to set on a site. +/// +/// The value is write-only across this API: it goes out in a request and is +/// never returned, because a provider that hands back decrypted secrets on a +/// list call is a provider this crate would be leaking through. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvVar { + /// The variable's name. + pub key: String, + /// Its value. + pub value: String, + /// The environments it applies to. Empty means every environment. + #[serde(default)] + pub targets: Vec, + /// Whether the provider should store it write-only. + #[serde(default)] + pub secret: bool, +} + +impl EnvVar { + /// A variable set in every environment. + #[must_use] + pub fn new(key: impl Into, value: impl Into) -> Self { + Self { + key: key.into(), + value: value.into(), + targets: Vec::new(), + secret: false, + } + } + + /// Restricts the variable to `targets`. + #[must_use] + pub fn with_targets(mut self, targets: Vec) -> Self { + self.targets = targets; + self + } + + /// Marks the variable as a secret the provider should not read back. + #[must_use] + pub fn secret(mut self) -> Self { + self.secret = true; + self + } + + /// Checks the variable before it is sent. + /// + /// # Errors + /// + /// Returns [`Error::EmptyEnvKey`] when the name is blank. + pub fn validate(&self) -> Result<()> { + if self.key.trim().is_empty() { + return Err(Error::EmptyEnvKey); + } + Ok(()) + } +} + +/// An environment variable that exists on a site, without its value. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvVarRecord { + /// The provider's identifier for it. + pub id: String, + /// The variable's name. + pub key: String, + /// The environments it applies to. + #[serde(default)] + pub targets: Vec, + /// Whether the provider stores it write-only. + #[serde(default)] + pub secret: bool, +} + +/// The kind of managed database to provision. +/// +/// A kind is a protocol, not a product: which vendor supplies a Postgres is the +/// provider's business, and on Vercel it depends on which marketplace +/// integration the account has installed. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum DatabaseKind { + /// A Postgres database. The default: it is what a Next.js application with + /// an ORM expects to find. + #[default] + Postgres, + /// A Redis-compatible key-value store. + Redis, + /// Blob or object storage. + Blob, + /// Another protocol, matched against the provider's product names. + Other(String), +} + +impl DatabaseKind { + /// The provider-independent slug for this kind. + #[must_use] + pub fn as_str(&self) -> &str { + match self { + Self::Postgres => "postgres", + Self::Redis => "redis", + Self::Blob => "blob", + Self::Other(name) => name, + } + } + + /// Product-name fragments that identify this kind on a provider. + /// + /// A managed Postgres is rarely called "postgres" in a catalogue — it is + /// Neon, or Supabase, or Prisma. Matching a kind to a product means + /// matching against the names vendors actually use. + #[must_use] + pub fn product_hints(&self) -> Vec<&str> { + match self { + Self::Postgres => vec![ + "postgres", + "neon", + "supabase", + "prisma-postgres", + "timescale", + ], + Self::Redis => vec!["redis", "upstash", "kv", "valkey"], + Self::Blob => vec!["blob", "storage", "bucket", "s3"], + Self::Other(name) => vec![name], + } + } +} + +/// What the caller wants a database to be. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DatabaseSpec { + /// The database's name within the account. + pub name: String, + /// The kind of database. + #[serde(default)] + pub kind: DatabaseKind, + /// A specific provider product to use, overriding the kind's matching. + /// + /// Set this when an account has several products that could serve the kind + /// and the choice matters — it is the only escape hatch from + /// [`DatabaseKind::product_hints`]. + #[serde(default)] + pub product: Option, +} + +impl DatabaseSpec { + /// A Postgres database called `name`. + #[must_use] + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + kind: DatabaseKind::Postgres, + product: None, + } + } + + /// Provisions a different kind of database. + #[must_use] + pub fn with_kind(mut self, kind: DatabaseKind) -> Self { + self.kind = kind; + self + } + + /// Pins the provider product instead of matching on the kind. + #[must_use] + pub fn with_product(mut self, product: impl Into) -> Self { + self.product = Some(product.into()); + self + } + + /// Checks the spec before an adapter provisions anything. + /// + /// # Errors + /// + /// Returns [`Error::EmptySiteName`] when the name is blank. A database and + /// a site share the rule and the variant: both are named resources on the + /// account. + pub fn validate(&self) -> Result<()> { + if self.name.trim().is_empty() { + return Err(Error::EmptySiteName); + } + Ok(()) + } +} + +/// A managed database that exists on a provider. +/// +/// `secret_keys` names the environment variables a connected site receives — +/// `DATABASE_URL` and friends — without their values. The values are the +/// provider's to inject; this crate never holds a connection string. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Database { + /// The provider's identifier for the database. + pub id: String, + /// Its name. + pub name: String, + /// The kind it serves. + pub kind: DatabaseKind, + /// The provider product behind it, when the provider names one. + #[serde(default)] + pub product: Option, + /// The provider's status for it, verbatim. + pub status: String, + /// The names of the environment variables a connected site receives. + #[serde(default)] + pub secret_keys: Vec, + /// The scope a later connection call needs, when the provider requires one. + /// + /// On Vercel this is the marketplace installation the store belongs to; + /// connecting the store to a project needs both identifiers. + #[serde(default)] + pub installation_id: Option, +} + +/// A custom domain on a site. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Domain { + /// The domain name. + pub name: String, + /// The site it points at. + pub site: String, + /// Whether the provider has verified ownership. + pub verified: bool, +} + +/// A dimension to break analytics down by. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum AnalyticsDimension { + /// The visitor's country. + Country, + /// Desktop, mobile, or tablet. + DeviceType, + /// The requested path. + RequestPath, + /// The referring host. + ReferrerHostname, + /// The visitor's browser. + BrowserName, + /// The visitor's operating system. + OsName, + /// The matched application route. + Route, +} + +impl AnalyticsDimension { + /// The provider-independent slug for this dimension. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Country => "country", + Self::DeviceType => "deviceType", + Self::RequestPath => "requestPath", + Self::ReferrerHostname => "referrerHostname", + Self::BrowserName => "browserName", + Self::OsName => "osName", + Self::Route => "route", + } + } +} + +/// A window of traffic to report on. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AnalyticsQuery { + /// The site to report on, by name or identifier. + pub site: String, + /// The start of the window, in milliseconds since the Unix epoch. + pub since_ms: u64, + /// The end of the window, in milliseconds since the Unix epoch. + pub until_ms: u64, + /// A dimension to break the totals down by, when one is wanted. + #[serde(default)] + pub breakdown: Option, + /// How many rows the breakdown may return. + #[serde(default = "default_analytics_limit")] + pub limit: u32, +} + +const fn default_analytics_limit() -> u32 { + 10 +} + +impl AnalyticsQuery { + /// A window between two epoch-millisecond timestamps. + #[must_use] + pub fn new(site: impl Into, since_ms: u64, until_ms: u64) -> Self { + Self { + site: site.into(), + since_ms, + until_ms, + breakdown: None, + limit: default_analytics_limit(), + } + } + + /// Breaks the totals down by `dimension`. + #[must_use] + pub fn with_breakdown(mut self, dimension: AnalyticsDimension) -> Self { + self.breakdown = Some(dimension); + self + } + + /// Returns at most `limit` breakdown rows. + #[must_use] + pub fn with_limit(mut self, limit: u32) -> Self { + self.limit = limit; + self + } + + /// Checks the query before an adapter sends it. + /// + /// # Errors + /// + /// Returns [`Error::EmptySiteName`] when the site is blank, or + /// [`Error::InvalidAnalyticsWindow`] when the window does not move forward. + pub fn validate(&self) -> Result<()> { + if self.site.trim().is_empty() { + return Err(Error::EmptySiteName); + } + if self.until_ms <= self.since_ms { + return Err(Error::InvalidAnalyticsWindow); + } + Ok(()) + } +} + +/// One row of an analytics breakdown. +/// +/// `metrics` holds whatever numbers the provider returned for the row rather +/// than a fixed pair of fields. Providers do not agree on what they count, and +/// a struct with `pageviews` and `visitors` would either drop a provider's +/// numbers or invent them. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnalyticsBucket { + /// The dimension value this row is for. + pub label: String, + /// The metrics the provider reported for it. + pub metrics: BTreeMap, +} + +/// Traffic a site served over a window. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnalyticsSummary { + /// The site the numbers are for. + pub site: String, + /// The window's start, in milliseconds since the Unix epoch. + pub since_ms: u64, + /// The window's end, in milliseconds since the Unix epoch. + pub until_ms: u64, + /// Distinct visitors, when the provider counts them. + #[serde(default)] + pub visitors: Option, + /// Page views, when the provider counts them. + #[serde(default)] + pub pageviews: Option, + /// The requested breakdown, when one was asked for. + #[serde(default)] + pub breakdown: Vec, +} diff --git a/src/launch/mod.rs b/src/launch/mod.rs new file mode 100644 index 0000000..b5d4585 --- /dev/null +++ b/src/launch/mod.rs @@ -0,0 +1,96 @@ +//! The standard way to put a Next.js application on the internet. +//! +//! [`launch`] is the whole flow in one call: make sure the site exists, give it +//! a database, set its environment, attach its domains, and deploy it. Callers +//! reach for the individual [`Host`] methods when they want one step; they reach +//! for this when they want a live URL. +//! +//! # The order is the point +//! +//! The steps do not commute, and getting them wrong produces a site that builds +//! successfully and does not work: +//! +//! 1. **The site**, created only if [`Host::find_site`] does not find it, so a +//! relaunch redeploys rather than failing on a name that is taken. +//! 2. **The database**, provisioned and *then connected to the site*. Connecting +//! is what puts `DATABASE_URL` into the site's environment. +//! 3. **The caller's environment variables**, after the database, so an explicit +//! variable overrides one the database injected rather than the reverse. +//! 4. **The domains**, before the deployment, so a production deployment is +//! aliased to them as it goes live instead of a request later. +//! 5. **The deployment**, last, because a Next.js build reads the environment at +//! build time. A database attached after the build is a database the built +//! pages cannot see. +//! +//! # Waiting +//! +//! [`launch`] returns as soon as the provider accepts the deployment, which is +//! before it has finished building. The returned [`Deployment`] carries the URL +//! the site will serve from and a non-terminal +//! [`status`](crate::host::types::DeploymentStatus); poll +//! [`Host::deployment`] until +//! [`is_terminal`](crate::host::types::DeploymentStatus::is_terminal). This crate +//! deliberately owns no timer and no retry loop: how long a caller is willing to +//! wait for a build is the caller's policy, and a hidden one is impossible to +//! cancel or report on. + +use crate::Result; +use crate::host::Host; +use crate::host::types::{DeployRequest, Deployment}; +use crate::launch::types::{Launch, LaunchPlan}; + +pub mod types; + +/// Runs a launch plan against a host and returns everything it produced. +/// +/// # Errors +/// +/// Returns the first failure from any step. A launch is not transactional: +/// earlier steps stay done, which is deliberate — a database that was +/// provisioned before a build failed should still be there when the build is +/// retried, not deleted and paid for twice. +pub async fn launch(host: &dyn Host, plan: &LaunchPlan) -> Result { + plan.validate()?; + + let (site, created_site) = match host.find_site(&plan.site.name).await? { + Some(site) => (site, false), + None => (host.create_site(&plan.site).await?, true), + }; + + let mut database = None; + let mut database_env_keys = Vec::new(); + if let Some(spec) = &plan.database { + let provisioned = host.provision_database(spec).await?; + database_env_keys = host.attach_database(&provisioned, &site.name).await?; + database = Some(provisioned); + } + + if !plan.env.is_empty() { + host.set_env(&site.name, &plan.env).await?; + } + + let mut domains = Vec::with_capacity(plan.domains.len()); + for domain in &plan.domains { + domains.push(host.add_domain(&site.name, domain).await?); + } + + let deployment: Deployment = host + .deploy( + &DeployRequest::new(&site.name, plan.bundle.clone()) + .with_framework(plan.site.framework.clone()) + .with_target(plan.target), + ) + .await?; + + Ok(Launch { + site, + created_site, + database, + database_env_keys, + domains, + deployment, + }) +} + +#[cfg(test)] +mod test; diff --git a/src/launch/test.rs b/src/launch/test.rs new file mode 100644 index 0000000..c3fcab7 --- /dev/null +++ b/src/launch/test.rs @@ -0,0 +1,321 @@ +//! Tests for the launch flow. +//! +//! The flow is tested against a mock of the provider API rather than a fake +//! [`Host`], because the thing worth testing is the order the real calls go out +//! in — and a hand-written double would only ever confirm the order it was +//! written to expect. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::{Value, json}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use super::*; +use crate::bundle::Bundle; +use crate::host::types::{DatabaseSpec, DeploymentStatus, DeploymentTarget, EnvVar, SiteSpec}; +use crate::providers::vercel::Vercel; +use crate::{Credentials, Error}; + +fn bundle() -> Bundle { + let mut bundle = Bundle::new(); + bundle.insert("package.json", b"{}".to_vec()).unwrap(); + bundle +} + +fn host(server: &MockServer) -> Vercel { + Vercel::with_base_url(Credentials::new("token").unwrap(), server.uri()).unwrap() +} + +async fn mount(server: &MockServer, verb: &str, route: &str, status: u16, body: Value) { + Mock::given(method(verb)) + .and(path(route.to_owned())) + .respond_with(ResponseTemplate::new(status).set_body_json(body)) + .mount(server) + .await; +} + +/// Every response a full launch needs, with the site absent to begin with. +async fn mount_launch(server: &MockServer) { + // The site is absent for the first lookup and present afterwards, which is + // what actually happens once the launch has created it. + Mock::given(method("GET")) + .and(path("/v9/projects/shop")) + .respond_with(ResponseTemplate::new(404)) + .up_to_n_times(1) + .mount(server) + .await; + mount( + server, + "GET", + "/v9/projects/shop", + 200, + json!({"id": "prj_1", "name": "shop"}), + ) + .await; + mount( + server, + "POST", + "/v11/projects", + 200, + json!({"id": "prj_1", "name": "shop", "framework": "nextjs"}), + ) + .await; + mount( + server, + "GET", + "/v1/integrations/configurations", + 200, + json!([{"id": "icfg_db", "slug": "neon"}]), + ) + .await; + mount( + server, + "GET", + "/v1/integrations/configuration/icfg_db/products", + 200, + json!({"products": [{"id": "iap_pg", "slug": "serverless", "primaryProtocol": "storage"}]}), + ) + .await; + mount( + server, + "POST", + "/v1/storage/stores/integration/direct", + 200, + json!({"store": { + "id": "store_1", + "name": "shop-db", + "status": "available", + "secrets": [{"name": "DATABASE_URL", "length": 60}], + "product": {"slug": "serverless", "integrationConfigurationId": "icfg_db"}, + }}), + ) + .await; + mount( + server, + "POST", + "/v1/integrations/installations/icfg_db/resources/store_1/connections", + 201, + json!({}), + ) + .await; + mount( + server, + "POST", + "/v10/projects/shop/env", + 201, + json!({"failed": []}), + ) + .await; + mount( + server, + "POST", + "/v10/projects/shop/domains", + 200, + json!({"name": "shop.com", "verified": false}), + ) + .await; + mount(server, "POST", "/v2/files", 200, json!({})).await; + mount( + server, + "POST", + "/v13/deployments", + 200, + json!({ + "id": "dpl_1", + "name": "shop", + "url": "shop.vercel.app", + "readyState": "QUEUED", + "target": "production", + }), + ) + .await; +} + +#[tokio::test] +async fn a_launch_creates_provisions_configures_and_deploys_in_that_order() { + let server = MockServer::start().await; + mount_launch(&server).await; + + let plan = LaunchPlan::new(SiteSpec::new("shop"), bundle()) + .with_database(DatabaseSpec::new("shop-db")) + .with_env(vec![EnvVar::new("NEXT_PUBLIC_NAME", "Shop")]) + .with_domains(vec!["shop.com".to_owned()]) + .into_production(); + + let result = launch(&host(&server), &plan).await.unwrap(); + + assert!(result.created_site); + assert_eq!(result.site.id, "prj_1"); + assert_eq!(result.database.as_ref().unwrap().id, "store_1"); + assert_eq!(result.database_env_keys, ["DATABASE_URL"]); + assert_eq!(result.domains[0].name, "shop.com"); + assert_eq!(result.deployment.status, DeploymentStatus::Queued); + assert_eq!(result.url(), Some("https://shop.vercel.app")); + + let routes: Vec = server + .received_requests() + .await + .unwrap() + .iter() + .map(|request| request.url.path().to_owned()) + .collect(); + + assert_eq!( + routes, + [ + // The site first, created because it was not there. + "/v9/projects/shop", + "/v11/projects", + // Then the database, provisioned and connected... + "/v1/integrations/configurations", + "/v1/integrations/configuration/icfg_db/products", + "/v1/storage/stores/integration/direct", + // Connecting the store to the project resolves the project's id. + "/v9/projects/shop", + "/v1/integrations/installations/icfg_db/resources/store_1/connections", + // ...before the caller's own variables, which may override it. + "/v10/projects/shop/env", + // The domain is attached before the deployment goes live. + "/v10/projects/shop/domains", + // The build is last: it reads everything above at build time. + "/v2/files", + "/v13/deployments", + ] + ); +} + +#[tokio::test] +async fn an_existing_site_is_reused_rather_than_recreated() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v9/projects/shop", + 200, + json!({"id": "prj_1", "name": "shop"}), + ) + .await; + mount(&server, "POST", "/v2/files", 200, json!({})).await; + mount( + &server, + "POST", + "/v13/deployments", + 200, + json!({"id": "dpl_2", "name": "shop", "readyState": "BUILDING"}), + ) + .await; + + let plan = LaunchPlan::new(SiteSpec::new("shop"), bundle()); + let result = launch(&host(&server), &plan).await.unwrap(); + + assert!(!result.created_site); + assert!(result.database.is_none()); + assert!(result.database_env_keys.is_empty()); + assert!(result.domains.is_empty()); + assert_eq!(result.url(), None); + + let routes: Vec = server + .received_requests() + .await + .unwrap() + .iter() + .map(|request| request.url.path().to_owned()) + .collect(); + assert_eq!( + routes, + ["/v9/projects/shop", "/v2/files", "/v13/deployments"] + ); +} + +#[tokio::test] +async fn a_failing_step_stops_the_launch_where_it_failed() { + let server = MockServer::start().await; + mount(&server, "GET", "/v9/projects/shop", 404, json!({})).await; + mount( + &server, + "POST", + "/v11/projects", + 409, + json!({"error": {"code": "taken", "message": "name already in use"}}), + ) + .await; + + let plan = LaunchPlan::new(SiteSpec::new("shop"), bundle()); + let error = launch(&host(&server), &plan).await.unwrap_err(); + + assert!(matches!(error, Error::Api { status: 409, .. }), "{error:?}"); + assert_eq!(server.received_requests().await.unwrap().len(), 2); +} + +#[tokio::test] +async fn an_invalid_plan_never_reaches_the_provider() { + let server = MockServer::start().await; + let plan = LaunchPlan::new(SiteSpec::new("shop"), Bundle::new()); + + assert_eq!( + launch(&host(&server), &plan).await.unwrap_err(), + Error::EmptyBundle + ); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[test] +fn a_plan_defaults_to_a_preview_with_nothing_attached() { + let plan = LaunchPlan::new(SiteSpec::new("shop"), bundle()); + + assert_eq!(plan.target, DeploymentTarget::Preview); + assert!(plan.database.is_none()); + assert!(plan.env.is_empty()); + assert!(plan.domains.is_empty()); + assert!(plan.validate().is_ok()); +} + +#[test] +fn a_plan_validates_everything_it_carries() { + let base = LaunchPlan::new(SiteSpec::new("shop"), bundle()); + + assert_eq!( + LaunchPlan::new(SiteSpec::new(" "), bundle()) + .validate() + .unwrap_err(), + Error::EmptySiteName + ); + assert_eq!( + LaunchPlan::new(SiteSpec::new("shop"), Bundle::new()) + .validate() + .unwrap_err(), + Error::EmptyBundle + ); + assert_eq!( + base.clone() + .with_database(DatabaseSpec::new(" ")) + .validate() + .unwrap_err(), + Error::EmptySiteName + ); + assert_eq!( + base.clone() + .with_env(vec![EnvVar::new(" ", "x")]) + .validate() + .unwrap_err(), + Error::EmptyEnvKey + ); + assert_eq!( + base.with_domains(vec![" ".to_owned()]) + .validate() + .unwrap_err(), + Error::EmptyDomain + ); +} + +#[test] +fn a_plan_round_trips_through_json() { + let plan = LaunchPlan::new(SiteSpec::new("shop"), bundle()) + .with_database(DatabaseSpec::new("shop-db")) + .into_production(); + + let json = serde_json::to_string(&plan).unwrap(); + + assert_eq!(serde_json::from_str::(&json).unwrap(), plan); +} diff --git a/src/launch/types.rs b/src/launch/types.rs new file mode 100644 index 0000000..cc18bef --- /dev/null +++ b/src/launch/types.rs @@ -0,0 +1,149 @@ +//! What a launch asks for, and what it produced. + +use serde::{Deserialize, Serialize}; + +use crate::Result; +use crate::bundle::Bundle; +use crate::host::types::{ + Database, DatabaseSpec, Deployment, DeploymentTarget, Domain, EnvVar, Site, SiteSpec, +}; + +/// Everything needed to put one application on the internet. +/// +/// # Examples +/// +/// ``` +/// # use tinyhosts::{Bundle, DatabaseSpec, EnvVar, LaunchPlan, SiteSpec}; +/// let mut bundle = Bundle::new(); +/// bundle.insert("package.json", br#"{"name":"shop"}"#)?; +/// bundle.insert("app/page.tsx", b"export default () =>

Shop

;")?; +/// +/// let plan = LaunchPlan::new(SiteSpec::new("shop"), bundle) +/// .with_database(DatabaseSpec::new("shop-db")) +/// .with_env(vec![EnvVar::new("NEXT_PUBLIC_NAME", "Shop")]) +/// .into_production(); +/// +/// assert!(plan.validate().is_ok()); +/// # Ok::<(), tinyhosts::Error>(()) +/// ``` +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LaunchPlan { + /// The site to deploy to, created if it is not already there. + pub site: SiteSpec, + /// The application's files. + pub bundle: Bundle, + /// A database to provision and connect, when the application needs one. + #[serde(default)] + pub database: Option, + /// Environment variables to set before the build. + #[serde(default)] + pub env: Vec, + /// Custom domains to attach. + #[serde(default)] + pub domains: Vec, + /// Which environment the deployment serves. + #[serde(default)] + pub target: DeploymentTarget, +} + +impl LaunchPlan { + /// A preview launch of `bundle` as `site`, with no database and no domains. + #[must_use] + pub fn new(site: SiteSpec, bundle: Bundle) -> Self { + Self { + site, + bundle, + database: None, + env: Vec::new(), + domains: Vec::new(), + target: DeploymentTarget::Preview, + } + } + + /// Provisions and connects a database as part of the launch. + #[must_use] + pub fn with_database(mut self, database: DatabaseSpec) -> Self { + self.database = Some(database); + self + } + + /// Sets environment variables before the build. + #[must_use] + pub fn with_env(mut self, env: Vec) -> Self { + self.env = env; + self + } + + /// Attaches custom domains. + #[must_use] + pub fn with_domains(mut self, domains: Vec) -> Self { + self.domains = domains; + self + } + + /// Sends the deployment to production rather than to a preview URL. + #[must_use] + pub fn into_production(mut self) -> Self { + self.target = DeploymentTarget::Production; + self + } + + /// Checks the plan before any of it runs. + /// + /// # Errors + /// + /// Returns the first failure from the site spec, the bundle, the database + /// spec, an environment variable, or a domain: [`Error::EmptySiteName`], + /// [`Error::EmptyBundle`], [`Error::EmptyEnvKey`], or [`Error::EmptyDomain`]. + /// + /// [`Error::EmptySiteName`]: crate::Error::EmptySiteName + /// [`Error::EmptyBundle`]: crate::Error::EmptyBundle + /// [`Error::EmptyEnvKey`]: crate::Error::EmptyEnvKey + /// [`Error::EmptyDomain`]: crate::Error::EmptyDomain + pub fn validate(&self) -> Result<()> { + self.site.validate()?; + if self.bundle.is_empty() { + return Err(crate::Error::EmptyBundle); + } + if let Some(database) = &self.database { + database.validate()?; + } + for var in &self.env { + var.validate()?; + } + for domain in &self.domains { + if domain.trim().is_empty() { + return Err(crate::Error::EmptyDomain); + } + } + Ok(()) + } +} + +/// What a launch produced. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Launch { + /// The site the application lives on. + pub site: Site, + /// Whether this launch created the site, rather than finding it. + pub created_site: bool, + /// The database that was provisioned, when the plan asked for one. + #[serde(default)] + pub database: Option, + /// The environment variable names the database injected into the site. + #[serde(default)] + pub database_env_keys: Vec, + /// The domains that were attached. + #[serde(default)] + pub domains: Vec, + /// The deployment, which is usually still building. + pub deployment: Deployment, +} + +impl Launch { + /// The URL the application will serve from, once the deployment is ready. + #[must_use] + pub fn url(&self) -> Option<&str> { + self.deployment.url.as_deref() + } +} diff --git a/src/lib.rs b/src/lib.rs index 170bf55..2ec5d57 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,38 +1,81 @@ -//! A production-ready starting point for an installable `TinyBus` module. +//! One API for putting a Next.js application, and the database behind it, on a +//! real hosting provider. //! -//! This crate is a template. It ships the layout, lint configuration, error -//! handling, testing, and documentation conventions described in `AGENTS.md`. -//! The compiled `cdylib` exports `TinyBus` module ABI v1 and serves the example -//! [`greet`] behavior over the bus. +//! `TinyHosts` is the hosting category of the `TinyHumans` stack: `OpenHuman` +//! vendors it, `OpenCompany` inherits it from there, and a user who pastes a +//! provider API key into `OpenCompany` gets a live site out of a workspace. The unit of work is +//! deliberately the whole thing — a site, a managed database wired into it, the +//! environment it reads, a domain, and the traffic it served — because that is +//! what "host this" means, and a library that only uploads files leaves every +//! caller to reinvent the other four steps. //! -//! # Layout +//! # The shape //! -//! - `src/error/` holds the crate-wide [`Error`] enum and the [`Result`] alias -//! returned by every fallible public function. -//! - Each feature area lives in its own module directory with a `mod.rs` -//! module root, an optional `types.rs`, and a `test.rs` holding its unit -//! tests. -//! - Every public item is re-exported from here, so downstream users have a -//! single predictable surface. -//! - `tinybus_module` adapts the public behavior to `TinyBus` and exports the -//! module descriptor, embedded manifest, and initialization entrypoint. +//! - [`Host`] is the provider-agnostic interface. [`Vercel`] is the first +//! implementation of it. +//! - [`ProviderKind`] and [`connect`] make the provider a configuration value +//! rather than a compile-time choice. +//! - [`Bundle`] is the application's files as the provider will receive them. +//! - [`launch()`] runs the whole flow in the one order that works. +//! - [`rpc`] is the same surface as JSON, for the callers in another process, +//! and the `TinyBus` module is a thin wrapper over it. //! //! # Example //! -//! ``` -//! use rust_template::{greet, Error}; +//! ```no_run +//! use tinyhosts::{Bundle, Credentials, DatabaseSpec, LaunchPlan, ProviderKind, SiteSpec, launch}; +//! +//! # async fn ship() -> tinyhosts::Result<()> { +//! let host = tinyhosts::connect(ProviderKind::Vercel, Credentials::new("vercel-token")?)?; //! -//! assert_eq!(greet("Ferris")?, "Hello, Ferris!"); -//! assert_eq!(greet(" ").unwrap_err(), Error::EmptyName); -//! # Ok::<(), rust_template::Error>(()) +//! let plan = LaunchPlan::new(SiteSpec::new("shop"), Bundle::from_dir("./shop")?) +//! .with_database(DatabaseSpec::new("shop-db")) +//! .into_production(); +//! +//! let result = launch(host.as_ref(), &plan).await?; +//! println!("building at {:?}", result.url()); +//! # Ok(()) +//! # } //! ``` //! -//! Replace the `greeting` module with the first real feature area, keep the -//! conventions, and update this documentation to describe the new crate. +//! The launch returns while the build is still running: poll +//! [`Host::deployment`] until its +//! [`status`](host::types::DeploymentStatus::is_terminal) settles. +//! +//! # What this crate does not do +//! +//! It does not hold a connection string, decrypt an environment variable, or +//! return a secret it was given. It does not wait, retry, or schedule — a +//! caller's patience is the caller's policy. And it does not pretend: a +//! capability a provider lacks is [`Error::Unsupported`], never a silent success. + +pub mod bundle; +pub mod credentials; +pub mod error; +pub mod host; +pub mod launch; +pub mod providers; +pub mod rpc; -mod error; -mod greeting; mod tinybus_module; +pub use bundle::{Bundle, EXCLUDED, SiteFile}; +pub use credentials::Credentials; pub use error::{Error, Result}; -pub use greeting::greet; +pub use host::Host; +pub use host::types::{ + AnalyticsBucket, AnalyticsDimension, AnalyticsQuery, AnalyticsSummary, Database, DatabaseKind, + DatabaseSpec, DeployRequest, Deployment, DeploymentStatus, DeploymentTarget, Domain, EnvVar, + EnvVarRecord, Framework, Site, SiteSpec, +}; +pub use launch::launch; +pub use launch::types::{Launch, LaunchPlan}; +pub use providers::{ProviderKind, connect, connect_from_env, connect_to}; + +#[cfg(feature = "vercel")] +pub use providers::vercel::Vercel; + +// `rpc`'s own names — `Request`, `Operation`, `Outcome` — are only unambiguous +// next to each other, so they stay in their module rather than being flattened +// into the crate root. +pub use rpc::{execute, execute_json}; diff --git a/src/providers/mod.rs b/src/providers/mod.rs new file mode 100644 index 0000000..4c11c01 --- /dev/null +++ b/src/providers/mod.rs @@ -0,0 +1,185 @@ +//! The hosting providers this crate can talk to. +//! +//! [`ProviderKind`] names a provider, says which environment variables hold its +//! credential, and [`connect`] turns a kind and a credential into a live +//! [`Host`]. A caller that goes through here never names a concrete adapter +//! type, which is what lets the provider be a configuration value rather than a +//! compile-time choice. +//! +//! A provider whose Cargo feature is off is still a [`ProviderKind`] — it can be +//! parsed, stored, and displayed — but [`connect`] refuses it with +//! [`Error::UnknownProvider`]. Failing at the connection is the useful place: +//! configuration is read long before a build's feature set is known. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +use crate::host::Host; +use crate::{Credentials, Error, Result}; + +#[cfg(feature = "vercel")] +pub mod vercel; + +/// A hosting provider. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ProviderKind { + /// Vercel: . + #[default] + Vercel, +} + +impl ProviderKind { + /// The provider's slug, as it appears in configuration and on the wire. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Vercel => "vercel", + } + } + + /// The environment variables that may hold this provider's API key, in the + /// order they are searched. + /// + /// The `TINYHOSTS_`-prefixed name comes first so a host running several + /// tools can give this crate its own token without disturbing the + /// provider's own CLI. + #[must_use] + pub const fn api_key_variables(self) -> &'static [&'static str] { + match self { + Self::Vercel => &["TINYHOSTS_VERCEL_TOKEN", "VERCEL_TOKEN"], + } + } + + /// The environment variables that may hold the team to act as. + #[must_use] + pub const fn team_variables(self) -> &'static [&'static str] { + match self { + Self::Vercel => &["TINYHOSTS_VERCEL_TEAM_ID", "VERCEL_TEAM_ID"], + } + } + + /// Reads this provider's credential from the process environment. + /// + /// # Errors + /// + /// Returns [`Error::MissingApiKey`], naming every variable it searched, when + /// none of them holds a non-blank value. + pub fn credentials_from_env(self) -> Result { + self.credentials_from(&|name| std::env::var(name).ok()) + } + + /// Reads this provider's credential through an arbitrary lookup. + /// + /// This is the testable form of [`credentials_from_env`](Self::credentials_from_env), + /// and the one to use when the values come from somewhere other than the + /// environment — a secret store, or a row in a database. + /// + /// # Errors + /// + /// Returns [`Error::MissingApiKey`] when the lookup yields no non-blank API + /// key. + pub fn credentials_from(self, lookup: &impl Fn(&str) -> Option) -> Result { + let first_set = |names: &[&str]| { + names + .iter() + .filter_map(|name| lookup(name)) + .find(|value| !value.trim().is_empty()) + }; + + let api_key = first_set(self.api_key_variables()).ok_or_else(|| Error::MissingApiKey { + provider: self.as_str().to_owned(), + variables: self.api_key_variables().join(", "), + })?; + + let credentials = Credentials::new(api_key)?; + Ok(match first_set(self.team_variables()) { + Some(team) => credentials.with_team(team), + None => credentials, + }) + } +} + +impl fmt::Display for ProviderKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for ProviderKind { + type Err = Error; + + /// Parses a provider slug, case-insensitively. + /// + /// # Errors + /// + /// Returns [`Error::UnknownProvider`] for anything else. + fn from_str(name: &str) -> Result { + match name.trim().to_ascii_lowercase().as_str() { + "vercel" => Ok(Self::Vercel), + _ => Err(Error::UnknownProvider { + name: name.to_owned(), + }), + } + } +} + +/// Connects to `kind` with `credentials`. +/// +/// Nothing is sent: the returned host is a client, and the credential is not +/// checked until the first call that uses it. +/// +/// # Errors +/// +/// Returns [`Error::UnknownProvider`] when this build has no adapter for `kind`, +/// or a provider error when the client cannot be constructed. +pub fn connect(kind: ProviderKind, credentials: Credentials) -> Result> { + connect_to(kind, credentials, None) +} + +/// Connects to `kind`, optionally through a different API root. +/// +/// `base_url` exists for a deployment that reaches the provider through an +/// egress proxy, and for a test suite that runs the adapter against a local mock +/// of the provider's API. `None` uses the provider's own root. +/// +/// # Errors +/// +/// Returns [`Error::UnknownProvider`] when this build has no adapter for `kind`, +/// or a provider error when the client cannot be constructed. +pub fn connect_to( + kind: ProviderKind, + credentials: Credentials, + base_url: Option<&str>, +) -> Result> { + match kind { + #[cfg(feature = "vercel")] + ProviderKind::Vercel => Ok(Box::new(match base_url { + Some(base_url) => vercel::Vercel::with_base_url(credentials, base_url)?, + None => vercel::Vercel::new(credentials)?, + })), + #[cfg(not(feature = "vercel"))] + ProviderKind::Vercel => { + let _ = (credentials, base_url); + Err(Error::UnknownProvider { + name: kind.as_str().to_owned(), + }) + } + } +} + +/// Connects to `kind` with the credential in the process environment. +/// +/// # Errors +/// +/// Returns [`Error::MissingApiKey`] when the environment holds no key for +/// `kind`, or anything [`connect`] returns. +pub fn connect_from_env(kind: ProviderKind) -> Result> { + connect(kind, kind.credentials_from_env()?) +} + +#[cfg(test)] +mod test; diff --git a/src/providers/test.rs b/src/providers/test.rs new file mode 100644 index 0000000..698bc87 --- /dev/null +++ b/src/providers/test.rs @@ -0,0 +1,138 @@ +//! Unit tests for provider naming, credential lookup, and connection. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::collections::HashMap; +use std::str::FromStr as _; + +use super::*; + +fn lookup(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option + use<> { + let map: HashMap = pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(); + + move |name: &str| map.get(name).cloned() +} + +#[test] +fn a_provider_names_itself() { + assert_eq!(ProviderKind::Vercel.as_str(), "vercel"); + assert_eq!(ProviderKind::Vercel.to_string(), "vercel"); + assert_eq!(ProviderKind::default(), ProviderKind::Vercel); +} + +#[test] +fn a_provider_parses_case_insensitively() { + assert_eq!( + ProviderKind::from_str("vercel").unwrap(), + ProviderKind::Vercel + ); + assert_eq!( + ProviderKind::from_str(" VERCEL ").unwrap(), + ProviderKind::Vercel + ); +} + +#[test] +fn an_unknown_provider_is_rejected_by_name() { + let error = ProviderKind::from_str("heroku").unwrap_err(); + + assert_eq!( + error, + Error::UnknownProvider { + name: "heroku".to_owned() + } + ); +} + +#[test] +fn the_prefixed_variable_wins() { + let credentials = ProviderKind::Vercel + .credentials_from(&lookup(&[ + ("TINYHOSTS_VERCEL_TOKEN", "ours"), + ("VERCEL_TOKEN", "the-cli-token"), + ])) + .unwrap(); + + assert_eq!(credentials.api_key(), "ours"); +} + +#[test] +fn the_providers_own_variable_is_a_fallback() { + let credentials = ProviderKind::Vercel + .credentials_from(&lookup(&[ + ("TINYHOSTS_VERCEL_TOKEN", " "), + ("VERCEL_TOKEN", "the-cli-token"), + ("VERCEL_TEAM_ID", "team_abc"), + ])) + .unwrap(); + + assert_eq!(credentials.api_key(), "the-cli-token"); + assert_eq!(credentials.team(), Some("team_abc")); +} + +#[test] +fn a_missing_key_names_every_variable_it_searched() { + let error = ProviderKind::Vercel + .credentials_from(&lookup(&[])) + .unwrap_err(); + + assert_eq!( + error, + Error::MissingApiKey { + provider: "vercel".to_owned(), + variables: "TINYHOSTS_VERCEL_TOKEN, VERCEL_TOKEN".to_owned(), + } + ); +} + +#[test] +fn reading_the_process_environment_either_finds_a_key_or_says_so() { + // The test suite must not depend on the machine it runs on: either outcome + // is correct here, and running the call is what exercises the lookup. + match ProviderKind::Vercel.credentials_from_env() { + Ok(credentials) => assert!(!credentials.api_key().is_empty()), + Err(error) => assert!(matches!(error, Error::MissingApiKey { .. })), + } +} + +#[test] +fn variable_lists_are_stable() { + assert_eq!( + ProviderKind::Vercel.api_key_variables(), + &["TINYHOSTS_VERCEL_TOKEN", "VERCEL_TOKEN"] + ); + assert_eq!( + ProviderKind::Vercel.team_variables(), + &["TINYHOSTS_VERCEL_TEAM_ID", "VERCEL_TEAM_ID"] + ); +} + +#[test] +fn connecting_yields_a_host_for_the_named_provider() { + let host = connect(ProviderKind::Vercel, Credentials::new("token").unwrap()).unwrap(); + + assert_eq!(host.kind(), ProviderKind::Vercel); +} + +#[test] +fn connecting_can_target_another_api_root() { + let host = connect_to( + ProviderKind::Vercel, + Credentials::new("token").unwrap(), + Some("http://127.0.0.1:1"), + ) + .unwrap(); + + assert_eq!(host.kind(), ProviderKind::Vercel); +} + +#[test] +fn connecting_from_the_environment_either_works_or_reports_the_missing_key() { + match connect_from_env(ProviderKind::Vercel) { + Ok(host) => assert_eq!(host.kind(), ProviderKind::Vercel), + Err(error) => assert!(matches!(error, Error::MissingApiKey { .. })), + } +} diff --git a/src/providers/vercel/http.rs b/src/providers/vercel/http.rs new file mode 100644 index 0000000..83f3691 --- /dev/null +++ b/src/providers/vercel/http.rs @@ -0,0 +1,264 @@ +//! The HTTP plumbing shared by every Vercel call. +//! +//! One place owns the base URL, the bearer token, the `teamId` query parameter +//! every request needs when a team is in play, and — most importantly — the +//! mapping from an HTTP status to an [`Error`] variant. A per-call mapping is +//! how a 403 ends up reported as a decoding failure in one code path and a +//! missing resource in another. + +use std::fmt; + +use reqwest::{Client, Method, RequestBuilder, Response, StatusCode}; +use serde::Serialize; +use serde::de::DeserializeOwned; + +use crate::providers::ProviderKind; +use crate::{Credentials, Error, Result}; + +/// Vercel's public API root. +pub(crate) const DEFAULT_BASE_URL: &str = "https://api.vercel.com"; + +/// An authenticated Vercel API client. +pub(crate) struct Http { + client: Client, + base_url: String, + token: String, + team: Option, +} + +/// Prints the base URL and the team, never the token. +impl fmt::Debug for Http { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Http") + .field("base_url", &self.base_url) + .field("team", &self.team) + .field("client", &self.client) + .field("token", &"") + .finish() + } +} + +impl Http { + /// Builds a client for `credentials` against `base_url`. + /// + /// # Errors + /// + /// Returns [`Error::Transport`] when the HTTP client cannot be built, which + /// in practice means no usable TLS backend. + pub(crate) fn new(credentials: Credentials, base_url: impl Into) -> Result { + let client = Client::builder() + .user_agent(concat!("tinyhosts/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|error| Error::Transport { + provider: provider(), + reason: error.to_string(), + })?; + + let (token, team) = credentials.into_parts(); + Ok(Self { + client, + base_url: base_url.into().trim_end_matches('/').to_owned(), + token, + team, + }) + } + + /// Starts a request, applying the bearer token and the team scope. + pub(crate) fn request( + &self, + method: Method, + path: &str, + query: &[(&str, String)], + ) -> RequestBuilder { + let mut builder = self + .client + .request(method, format!("{}{path}", self.base_url)) + .bearer_auth(&self.token) + .query(query); + + if let Some(team) = &self.team { + builder = builder.query(&[("teamId", team)]); + } + builder + } + + /// Sends a request and decodes a JSON body. + /// + /// # Errors + /// + /// Returns the mapped provider error, or [`Error::Decode`] when the body is + /// not the shape this crate expects. + pub(crate) async fn json( + &self, + builder: RequestBuilder, + resource: &str, + ) -> Result { + let response = self.send(builder, resource).await?; + let bytes = response.bytes().await.map_err(|error| Error::Transport { + provider: provider(), + reason: error.to_string(), + })?; + + serde_json::from_slice(&bytes).map_err(|error| Error::Decode { + provider: provider(), + resource: resource.to_owned(), + reason: error.to_string(), + }) + } + + /// Sends a request, decoding a JSON body but treating "missing" as `None`. + /// + /// # Errors + /// + /// Returns the mapped provider error for anything but a 404, or + /// [`Error::Decode`] for an unexpected body. + pub(crate) async fn optional_json( + &self, + builder: RequestBuilder, + resource: &str, + ) -> Result> { + match self.json(builder, resource).await { + Ok(value) => Ok(Some(value)), + Err(Error::NotFound { .. }) => Ok(None), + Err(error) => Err(error), + } + } + + /// Sends a request and discards a successful body. + /// + /// # Errors + /// + /// Returns the mapped provider error. + pub(crate) async fn discard(&self, builder: RequestBuilder, resource: &str) -> Result<()> { + self.send(builder, resource).await.map(|_| ()) + } + + /// Sends a JSON body and decodes a JSON response. + /// + /// # Errors + /// + /// Returns the mapped provider error, or [`Error::Decode`]. + pub(crate) async fn post_json( + &self, + path: &str, + query: &[(&str, String)], + body: &B, + resource: &str, + ) -> Result { + let builder = self.request(Method::POST, path, query).json(body); + self.json(builder, resource).await + } + + /// Sends a JSON body and discards the response. + /// + /// # Errors + /// + /// Returns the mapped provider error. + pub(crate) async fn post_discard( + &self, + path: &str, + query: &[(&str, String)], + body: &B, + resource: &str, + ) -> Result<()> { + let builder = self.request(Method::POST, path, query).json(body); + self.discard(builder, resource).await + } + + /// Reads a JSON resource. + /// + /// # Errors + /// + /// Returns the mapped provider error, or [`Error::Decode`]. + pub(crate) async fn get_json( + &self, + path: &str, + query: &[(&str, String)], + resource: &str, + ) -> Result { + let builder = self.request(Method::GET, path, query); + self.json(builder, resource).await + } + + /// Sends a request and maps a failed status onto an [`Error`]. + /// + /// # Errors + /// + /// Returns [`Error::Transport`] when the request never completed, and + /// otherwise the variant matching the status: 401 [`Error::Unauthorized`], + /// 403 [`Error::Forbidden`], 404 [`Error::NotFound`], 429 + /// [`Error::RateLimited`], anything else [`Error::Api`]. + async fn send(&self, builder: RequestBuilder, resource: &str) -> Result { + let response = builder.send().await.map_err(|error| Error::Transport { + provider: provider(), + reason: error.to_string(), + })?; + + let status = response.status(); + if status.is_success() { + return Ok(response); + } + + let message = message_of(response).await; + Err(match status { + StatusCode::UNAUTHORIZED => Error::Unauthorized { + provider: provider(), + }, + StatusCode::FORBIDDEN => Error::Forbidden { + provider: provider(), + resource: resource.to_owned(), + }, + StatusCode::NOT_FOUND => Error::NotFound { + provider: provider(), + resource: resource.to_owned(), + }, + StatusCode::TOO_MANY_REQUESTS => Error::RateLimited { + provider: provider(), + }, + other => Error::Api { + provider: provider(), + status: other.as_u16(), + resource: resource.to_owned(), + message, + }, + }) + } +} + +/// Reads the message out of a failed response. +/// +/// Vercel wraps failures as `{"error": {"code": ..., "message": ...}}`. A body +/// that is not that shape is reported verbatim, because an HTML error page from +/// a proxy in front of the API is exactly the kind of thing a reader needs to +/// see rather than have summarized as "unexpected response". +async fn message_of(response: Response) -> String { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + + if let Ok(envelope) = serde_json::from_str::(&body) { + return envelope.error.message; + } + + let trimmed = body.trim(); + if trimmed.is_empty() { + return status.canonical_reason().unwrap_or("unknown").to_owned(); + } + trimmed.chars().take(400).collect() +} + +/// Vercel's error body. +#[derive(serde::Deserialize)] +struct ErrorEnvelope { + error: ErrorDetail, +} + +#[derive(serde::Deserialize)] +struct ErrorDetail { + message: String, +} + +/// The provider name every error from this module carries. +fn provider() -> String { + ProviderKind::Vercel.as_str().to_owned() +} diff --git a/src/providers/vercel/mod.rs b/src/providers/vercel/mod.rs new file mode 100644 index 0000000..597449f --- /dev/null +++ b/src/providers/vercel/mod.rs @@ -0,0 +1,556 @@ +//! The Vercel adapter. +//! +//! [`Vercel`] implements [`Host`] against Vercel's REST API: sites are +//! projects, deployments are file uploads followed by a build, databases are +//! marketplace stores connected to a project, and analytics come from the web +//! analytics query API. +//! +//! # Deploying without Git +//! +//! Vercel's usual path is a Git integration. This adapter uses the other one: it +//! uploads each file to `POST /v2/files` keyed by its SHA-1 digest, then creates +//! a deployment referencing those digests. That is what makes a workspace with +//! no repository behind it deployable, and it is why the digest algorithm is +//! SHA-1 — Vercel's `x-vercel-digest` header defines it. +//! +//! # Databases +//! +//! Vercel does not run databases; its marketplace partners do. Provisioning one +//! therefore means finding an installed integration whose product serves the +//! requested [`DatabaseKind`](crate::DatabaseKind), creating a store from it, and connecting that +//! store to the project — at which point Vercel injects the connection +//! variables into the project's environment. This crate never sees them, which +//! is why [`Host::attach_database`] returns names rather than values. + +use async_trait::async_trait; +use reqwest::Method; +use serde_json::Value; + +use crate::host::Host; +use crate::host::types::{ + AnalyticsBucket, AnalyticsQuery, AnalyticsSummary, Database, DatabaseSpec, DeployRequest, + Deployment, DeploymentTarget, Domain, EnvVar, EnvVarRecord, Framework, Site, SiteSpec, +}; +use crate::providers::ProviderKind; +use crate::{Credentials, Error, Result}; + +use self::http::{DEFAULT_BASE_URL, Http}; +use self::wire::{ + AnalyticsEnvelope, Configuration, ConnectResource, CreateDeployment, CreateDomain, + CreateEnvVar, CreateProject, CreateStore, DeploymentBody, Deployments, DomainBody, Domains, + Envs, Products, Project, ProjectSettings, Projects, StoreEnvelope, UploadedFile, +}; + +mod http; +mod wire; + +/// A Vercel account, reached through the unified hosting model. +#[derive(Debug)] +pub struct Vercel { + http: Http, +} + +impl Vercel { + /// Connects to Vercel's public API with `credentials`. + /// + /// # Errors + /// + /// Returns [`Error::Transport`] when the HTTP client cannot be built. + pub fn new(credentials: Credentials) -> Result { + Self::with_base_url(credentials, DEFAULT_BASE_URL) + } + + /// Connects to a different API root. + /// + /// This exists for the test suite, which runs the adapter against a local + /// mock of the REST API, and for a deployment that reaches Vercel through an + /// egress proxy. + /// + /// # Errors + /// + /// Returns [`Error::Transport`] when the HTTP client cannot be built. + pub fn with_base_url(credentials: Credentials, base_url: impl Into) -> Result { + Ok(Self { + http: Http::new(credentials, base_url)?, + }) + } + + /// Resolves a site name to the project identifier some endpoints require. + /// + /// Most project endpoints accept a name or an identifier; the promote and + /// connect endpoints take only the identifier. + async fn project_id(&self, site: &str) -> Result { + self.find_site(site) + .await? + .map(|site| site.id) + .ok_or_else(|| Error::NotFound { + provider: ProviderKind::Vercel.as_str().to_owned(), + resource: format!("project {site}"), + }) + } + + /// Uploads one deployment file, keyed by its SHA-1 digest. + async fn upload(&self, digest: &str, contents: &[u8]) -> Result<()> { + let builder = self + .http + .request(Method::POST, "/v2/files", &[]) + .header("x-vercel-digest", digest) + .header("content-type", "application/octet-stream") + .body(contents.to_vec()); + + self.http.discard(builder, "deployment file").await + } + + /// Finds an installed integration and product that can serve `spec`. + /// + /// Returns the installation identifier and the product identifier, in that + /// order. + async fn find_product(&self, spec: &DatabaseSpec) -> Result<(String, String)> { + let configurations: Vec = self + .http + .get_json( + "/v1/integrations/configurations", + &[("view", "account".to_owned())], + "integrations", + ) + .await?; + + for configuration in configurations { + let products: Products = self + .http + .get_json( + &format!( + "/v1/integrations/configuration/{}/products", + configuration.id + ), + &[], + "integration products", + ) + .await?; + + let matched = products.products.into_iter().find(|product| { + match spec.product.as_deref() { + // A pinned product is matched exactly, by slug or by id, and + // never by the kind's hints: pinning exists to overrule them. + Some(pinned) => { + product.slug.eq_ignore_ascii_case(pinned) || product.id == pinned + } + None => product.serves(&spec.kind, configuration.slug.as_deref()), + } + }); + + if let Some(product) = matched { + return Ok((configuration.id, product.id)); + } + } + + Err(Error::NoDatabaseProduct { + provider: ProviderKind::Vercel.as_str().to_owned(), + kind: spec + .product + .clone() + .unwrap_or_else(|| spec.kind.as_str().to_owned()), + }) + } + + /// Reads the breakdown rows for an analytics query. + async fn breakdown(&self, query: &AnalyticsQuery) -> Result> { + let Some(dimension) = query.breakdown else { + return Ok(Vec::new()); + }; + + let envelope: AnalyticsEnvelope = self + .http + .get_json( + "/v1/query/web-analytics/visits/aggregate", + &[ + ("projectId", query.site.clone()), + ("by", dimension.as_str().to_owned()), + ("since", query.since_ms.to_string()), + ("until", query.until_ms.to_string()), + ("limit", query.limit.to_string()), + ], + "analytics breakdown", + ) + .await?; + + Ok(buckets(dimension.as_str(), &envelope.data)) + } +} + +#[async_trait] +impl Host for Vercel { + fn kind(&self) -> ProviderKind { + ProviderKind::Vercel + } + + async fn create_site(&self, spec: &SiteSpec) -> Result { + spec.validate()?; + + let body = CreateProject { + name: spec.name.trim(), + framework: framework_param(&spec.framework), + }; + let project: Project = self + .http + .post_json("/v11/projects", &[], &body, "project") + .await?; + + Ok(project.into_site()) + } + + async fn find_site(&self, name: &str) -> Result> { + let builder = self + .http + .request(Method::GET, &format!("/v9/projects/{name}"), &[]); + let project: Option = self.http.optional_json(builder, "project").await?; + + Ok(project.map(Project::into_site)) + } + + async fn list_sites(&self, limit: u32) -> Result> { + let projects: Projects = self + .http + .get_json( + "/v10/projects", + &[("limit", limit.to_string())], + "project list", + ) + .await?; + + Ok(projects + .projects + .into_iter() + .map(Project::into_site) + .collect()) + } + + async fn set_env(&self, site: &str, vars: &[EnvVar]) -> Result<()> { + let mut body = Vec::with_capacity(vars.len()); + for var in vars { + var.validate()?; + body.push(CreateEnvVar { + key: var.key.trim().to_owned(), + value: var.value.clone(), + r#type: if var.secret { "sensitive" } else { "encrypted" }, + target: env_targets(&var.targets), + }); + } + + if body.is_empty() { + return Ok(()); + } + + self.http + .post_discard( + &format!("/v10/projects/{site}/env"), + &[("upsert", "true".to_owned())], + &body, + "environment variables", + ) + .await + } + + async fn list_env(&self, site: &str) -> Result> { + let envs: Envs = self + .http + .get_json( + &format!("/v10/projects/{site}/env"), + &[], + "environment variables", + ) + .await?; + + Ok(envs + .envs + .into_iter() + .map(wire::EnvBody::into_record) + .collect()) + } + + async fn provision_database(&self, spec: &DatabaseSpec) -> Result { + spec.validate()?; + + let (installation, product) = self.find_product(spec).await?; + let body = CreateStore { + name: spec.name.trim(), + integration_configuration_id: &installation, + integration_product_id_or_slug: &product, + }; + + let envelope: StoreEnvelope = self + .http + .post_json( + "/v1/storage/stores/integration/direct", + &[], + &body, + "database", + ) + .await?; + + let store = envelope.store.ok_or_else(|| Error::Decode { + provider: ProviderKind::Vercel.as_str().to_owned(), + resource: "database".to_owned(), + reason: "the response carried no store".to_owned(), + })?; + + let mut database = store.into_database(spec.name.trim(), spec.kind.clone()); + if database.installation_id.is_none() { + database.installation_id = Some(installation); + } + + if database.status == "error" { + return Err(Error::DatabaseNotReady { + name: database.name, + status: database.status, + }); + } + + Ok(database) + } + + async fn attach_database(&self, database: &Database, site: &str) -> Result> { + let installation = database + .installation_id + .as_deref() + .ok_or_else(|| Error::NotFound { + provider: ProviderKind::Vercel.as_str().to_owned(), + resource: format!("marketplace installation for database {}", database.name), + })?; + + let project = self.project_id(site).await?; + let body = ConnectResource { + project_id: &project, + env_var_environments: vec!["production", "preview", "development"], + }; + + self.http + .post_discard( + &format!( + "/v1/integrations/installations/{installation}/resources/{}/connections", + database.id + ), + &[], + &body, + "database connection", + ) + .await?; + + Ok(database.secret_keys.clone()) + } + + async fn deploy(&self, request: &DeployRequest) -> Result { + request.validate()?; + + let mut files = Vec::with_capacity(request.bundle.len()); + for file in request.bundle.files() { + let sha = digest(&file.contents); + self.upload(&sha, &file.contents).await?; + files.push(UploadedFile { + file: file.path.clone(), + sha, + size: file.len(), + }); + } + + let site = request.site.trim(); + let body = CreateDeployment { + name: site, + files, + // A preview deployment omits the field. Vercel reads an absent + // target as a preview, and rejects the literal string. + target: match request.target { + DeploymentTarget::Production => Some("production"), + DeploymentTarget::Preview => None, + }, + project_settings: ProjectSettings { + framework: framework_param(&request.framework), + }, + }; + + let deployment: DeploymentBody = self + .http + .post_json( + "/v13/deployments", + &[ + ("forceNew", "1".to_owned()), + ("skipAutoDetectionConfirmation", "1".to_owned()), + ], + &body, + "deployment", + ) + .await?; + + Ok(deployment.into_deployment(site)) + } + + async fn deployment(&self, id: &str) -> Result { + let deployment: DeploymentBody = self + .http + .get_json(&format!("/v13/deployments/{id}"), &[], "deployment") + .await?; + + Ok(deployment.into_deployment("")) + } + + async fn list_deployments(&self, site: &str, limit: u32) -> Result> { + let deployments: Deployments = self + .http + .get_json( + "/v7/deployments", + &[("projectId", site.to_owned()), ("limit", limit.to_string())], + "deployment list", + ) + .await?; + + Ok(deployments + .deployments + .into_iter() + .map(|deployment| deployment.into_deployment(site)) + .collect()) + } + + async fn promote(&self, site: &str, deployment: &str) -> Result<()> { + let project = self.project_id(site).await?; + let builder = self.http.request( + Method::POST, + &format!("/v10/projects/{project}/promote/{deployment}"), + &[], + ); + + self.http.discard(builder, "promotion").await + } + + async fn add_domain(&self, site: &str, domain: &str) -> Result { + let name = domain.trim(); + if name.is_empty() { + return Err(Error::EmptyDomain); + } + + let added: DomainBody = self + .http + .post_json( + &format!("/v10/projects/{site}/domains"), + &[], + &CreateDomain { name }, + "domain", + ) + .await?; + + Ok(added.into_domain(site)) + } + + async fn list_domains(&self, site: &str) -> Result> { + let domains: Domains = self + .http + .get_json(&format!("/v9/projects/{site}/domains"), &[], "domain list") + .await?; + + Ok(domains + .domains + .into_iter() + .map(|domain| domain.into_domain(site)) + .collect()) + } + + async fn analytics(&self, query: &AnalyticsQuery) -> Result { + query.validate()?; + + let totals: AnalyticsEnvelope = self + .http + .get_json( + "/v1/query/web-analytics/visits/count", + &[ + ("projectId", query.site.clone()), + ("since", query.since_ms.to_string()), + ("until", query.until_ms.to_string()), + ], + "analytics", + ) + .await?; + + Ok(AnalyticsSummary { + site: query.site.clone(), + since_ms: query.since_ms, + until_ms: query.until_ms, + visitors: totals.data.get("visitors").and_then(Value::as_u64), + pageviews: totals.data.get("pageviews").and_then(Value::as_u64), + breakdown: self.breakdown(query).await?, + }) + } +} + +/// The `framework` value Vercel wants for a unified framework. +/// +/// Static output has no framework on Vercel: the field is null, and sending +/// "static" is rejected. +fn framework_param(framework: &Framework) -> Option<&str> { + match framework { + Framework::Static => None, + other => Some(other.as_str()), + } +} + +/// The environments an environment variable applies to. +/// +/// An empty list means every environment, `development` included — a variable +/// the local `next dev` cannot see is a variable that works everywhere except +/// on the machine where it is being written. +fn env_targets(targets: &[DeploymentTarget]) -> Vec<&'static str> { + if targets.is_empty() { + return vec!["production", "preview", "development"]; + } + + let mut names: Vec<&'static str> = targets + .iter() + .map(|target| match target { + DeploymentTarget::Production => "production", + DeploymentTarget::Preview => "preview", + }) + .collect(); + names.dedup(); + names +} + +/// The SHA-1 digest of a deployment file, hex encoded, as `x-vercel-digest` +/// requires. +fn digest(contents: &[u8]) -> String { + use sha1::{Digest as _, Sha1}; + + let mut hasher = Sha1::new(); + hasher.update(contents); + hex::encode(hasher.finalize()) +} + +/// Reads analytics breakdown rows out of the provider's untyped `data`. +/// +/// Every numeric field becomes a metric, because providers do not agree on what +/// they count and dropping the ones this crate did not anticipate would be +/// silently losing the answer. +fn buckets(dimension: &str, data: &Value) -> Vec { + data.as_array() + .map(|rows| { + rows.iter() + .filter_map(|row| { + let row = row.as_object()?; + let metrics = row + .iter() + .filter_map(|(key, value)| Some((key.clone(), value.as_f64()?))) + .collect(); + + Some(AnalyticsBucket { + label: row + .get(dimension) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + metrics, + }) + }) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod test; diff --git a/src/providers/vercel/test.rs b/src/providers/vercel/test.rs new file mode 100644 index 0000000..6572daf --- /dev/null +++ b/src/providers/vercel/test.rs @@ -0,0 +1,1134 @@ +//! Tests for the Vercel adapter. +//! +//! Every test runs the real adapter — real request construction, real status +//! mapping, real response translation — against a local mock of Vercel's REST +//! API. Nothing here touches the network, and nothing here needs a token. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::{Value, json}; +use wiremock::matchers::{body_json, header, header_exists, method, path, query_param}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use super::*; +use crate::bundle::Bundle; +use crate::host::types::{AnalyticsDimension, DatabaseKind, DeploymentStatus, Framework}; + +fn host(server: &MockServer) -> Vercel { + Vercel::with_base_url(Credentials::new("token").unwrap(), server.uri()).unwrap() +} + +fn bundle() -> Bundle { + let mut bundle = Bundle::new(); + bundle.insert("package.json", b"{}".to_vec()).unwrap(); + bundle +} + +/// Mounts one JSON response. +async fn mount(server: &MockServer, verb: &str, route: &str, status: u16, body: Value) { + Mock::given(method(verb)) + .and(path(route.to_owned())) + .respond_with(ResponseTemplate::new(status).set_body_json(body)) + .mount(server) + .await; +} + +#[tokio::test] +async fn creates_a_next_js_project() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v11/projects")) + .and(header("authorization", "Bearer token")) + .and(body_json(json!({"name": "shop", "framework": "nextjs"}))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "prj_1", + "name": "shop", + "framework": "nextjs", + "createdAt": 1_700_000_000_000_u64, + }))) + .mount(&server) + .await; + + let site = host(&server) + .create_site(&SiteSpec::new(" shop ")) + .await + .unwrap(); + + assert_eq!(site.id, "prj_1"); + assert_eq!(site.name, "shop"); + assert_eq!(site.framework, Some(Framework::NextJs)); + assert_eq!(site.created_at_ms, Some(1_700_000_000_000)); +} + +#[tokio::test] +async fn a_static_site_is_sent_without_a_framework() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v11/projects")) + .and(body_json(json!({"name": "docs", "framework": Value::Null}))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"id": "prj_2", "name": "docs", "framework": Value::Null})), + ) + .mount(&server) + .await; + + let site = host(&server) + .create_site(&SiteSpec::new("docs").with_framework(Framework::Static)) + .await + .unwrap(); + + assert_eq!(site.framework, None); +} + +#[tokio::test] +async fn an_unknown_framework_is_reported_as_the_provider_named_it() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v9/projects/docs", + 200, + json!({"id": "prj_3", "name": "docs", "framework": "astro"}), + ) + .await; + + let site = host(&server).find_site("docs").await.unwrap().unwrap(); + + assert_eq!(site.framework, Some(Framework::Other("astro".to_owned()))); +} + +#[tokio::test] +async fn a_blank_site_name_never_reaches_the_provider() { + let server = MockServer::start().await; + + let error = host(&server) + .create_site(&SiteSpec::new(" ")) + .await + .unwrap_err(); + + assert_eq!(error, Error::EmptySiteName); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn a_missing_site_is_not_an_error() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v9/projects/absent", + 404, + json!({"error": {"code": "not_found", "message": "not found"}}), + ) + .await; + + assert!(host(&server).find_site("absent").await.unwrap().is_none()); +} + +#[tokio::test] +async fn lists_projects_up_to_a_limit() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v10/projects")) + .and(query_param("limit", "2")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "projects": [ + {"id": "prj_1", "name": "shop"}, + {"id": "prj_2", "name": "docs"}, + ] + }))) + .mount(&server) + .await; + + let sites = host(&server).list_sites(2).await.unwrap(); + + assert_eq!(sites.len(), 2); + assert_eq!(sites[1].name, "docs"); +} + +#[tokio::test] +async fn an_empty_project_list_decodes() { + let server = MockServer::start().await; + mount(&server, "GET", "/v10/projects", 200, json!({})).await; + + assert!(host(&server).list_sites(5).await.unwrap().is_empty()); +} + +#[tokio::test] +async fn sets_environment_variables_with_an_upsert() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v10/projects/shop/env")) + .and(query_param("upsert", "true")) + .and(body_json(json!([ + { + "key": "DATABASE_URL", + "value": "postgres://", + "type": "encrypted", + "target": ["production", "preview", "development"], + }, + { + "key": "STRIPE_KEY", + "value": "sk_live", + "type": "sensitive", + "target": ["production"], + }, + ]))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({"failed": []}))) + .mount(&server) + .await; + + host(&server) + .set_env( + "shop", + &[ + EnvVar::new(" DATABASE_URL ", "postgres://"), + EnvVar::new("STRIPE_KEY", "sk_live") + .with_targets(vec![DeploymentTarget::Production]) + .secret(), + ], + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn setting_no_variables_sends_no_request() { + let server = MockServer::start().await; + + host(&server).set_env("shop", &[]).await.unwrap(); + + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn a_blank_variable_name_never_reaches_the_provider() { + let server = MockServer::start().await; + + let error = host(&server) + .set_env("shop", &[EnvVar::new(" ", "x")]) + .await + .unwrap_err(); + + assert_eq!(error, Error::EmptyEnvKey); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn lists_environment_variables_without_their_values() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v10/projects/shop/env", + 200, + json!({"envs": [ + {"id": "env_1", "key": "DATABASE_URL", "target": ["production", "development"], "type": "encrypted"}, + {"id": "env_2", "key": "STRIPE_KEY", "target": "production", "type": "sensitive"}, + {"key": "LEGACY", "value": "leaked?"}, + ]}), + ) + .await; + + let vars = host(&server).list_env("shop").await.unwrap(); + + assert_eq!(vars[0].key, "DATABASE_URL"); + // `development` has no unified equivalent and is dropped. + assert_eq!(vars[0].targets, vec![DeploymentTarget::Production]); + assert!(!vars[0].secret); + assert_eq!(vars[1].targets, vec![DeploymentTarget::Production]); + assert!(vars[1].secret); + assert_eq!(vars[2].id, ""); + assert!(vars[2].targets.is_empty()); + + let rendered = serde_json::to_string(&vars).unwrap(); + assert!(!rendered.contains("leaked?"), "{rendered}"); +} + +#[tokio::test] +async fn uploads_every_file_then_creates_the_deployment() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v2/files")) + .and(header_exists("x-vercel-digest")) + .and(header("content-type", "application/octet-stream")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .expect(2) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v13/deployments")) + .and(query_param("forceNew", "1")) + .and(query_param("skipAutoDetectionConfirmation", "1")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "dpl_1", + "name": "shop", + "url": "shop-abc.vercel.app", + "readyState": "BUILDING", + "createdAt": 5_u64, + }))) + .mount(&server) + .await; + + let mut bundle = bundle(); + bundle.insert("app/page.tsx", b"page".to_vec()).unwrap(); + + let deployment = host(&server) + .deploy(&DeployRequest::new("shop", bundle)) + .await + .unwrap(); + + assert_eq!(deployment.id, "dpl_1"); + assert_eq!(deployment.site, "shop"); + // A bare host is returned as a URL a reader can open. + assert_eq!( + deployment.url.as_deref(), + Some("https://shop-abc.vercel.app") + ); + assert_eq!(deployment.status, DeploymentStatus::Building); + assert_eq!(deployment.target, DeploymentTarget::Preview); + assert_eq!(deployment.created_at_ms, Some(5)); + + let requests = server.received_requests().await.unwrap(); + let created: Value = serde_json::from_slice(&requests[2].body).unwrap(); + assert_eq!(created["name"], "shop"); + assert_eq!(created["projectSettings"]["framework"], "nextjs"); + // A preview omits the target rather than naming it. + assert!(created.get("target").is_none(), "{created}"); + let files = created["files"].as_array().unwrap(); + assert_eq!(files.len(), 2); + assert_eq!(files[0]["file"], "package.json"); + assert_eq!(files[0]["size"], 2); + assert_eq!(files[0]["sha"].as_str().unwrap().len(), 40); +} + +#[tokio::test] +async fn a_production_deployment_names_its_target() { + let server = MockServer::start().await; + mount(&server, "POST", "/v2/files", 200, json!({})).await; + mount( + &server, + "POST", + "/v13/deployments", + 200, + json!({"id": "dpl_2", "url": "https://shop.com", "readyState": "READY", "target": "production"}), + ) + .await; + + let deployment = host(&server) + .deploy(&DeployRequest::new("shop", bundle()).with_target(DeploymentTarget::Production)) + .await + .unwrap(); + + assert_eq!(deployment.target, DeploymentTarget::Production); + assert!(deployment.status.is_ready()); + // An absolute URL is left alone. + assert_eq!(deployment.url.as_deref(), Some("https://shop.com")); + + let requests = server.received_requests().await.unwrap(); + let created: Value = serde_json::from_slice(&requests[1].body).unwrap(); + assert_eq!(created["target"], "production"); +} + +#[tokio::test] +async fn an_empty_bundle_never_reaches_the_provider() { + let server = MockServer::start().await; + + let error = host(&server) + .deploy(&DeployRequest::new("shop", Bundle::new())) + .await + .unwrap_err(); + + assert_eq!(error, Error::EmptyBundle); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn a_failed_upload_stops_the_deployment() { + let server = MockServer::start().await; + mount( + &server, + "POST", + "/v2/files", + 500, + json!({"error": {"code": "oops", "message": "disk on fire"}}), + ) + .await; + + let error = host(&server) + .deploy(&DeployRequest::new("shop", bundle())) + .await + .unwrap_err(); + + assert_eq!( + error, + Error::Api { + provider: "vercel".to_owned(), + status: 500, + resource: "deployment file".to_owned(), + message: "disk on fire".to_owned(), + } + ); +} + +#[tokio::test] +async fn reads_a_deployment_and_its_failure_message() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v13/deployments/dpl_9", + 200, + json!({ + "id": "dpl_9", + "name": "shop", + "readyState": "ERROR", + "errorMessage": "build failed", + }), + ) + .await; + + let deployment = host(&server).deployment("dpl_9").await.unwrap(); + + assert_eq!(deployment.status, DeploymentStatus::Failed); + assert!(deployment.status.is_terminal()); + assert_eq!(deployment.error_message.as_deref(), Some("build failed")); + assert_eq!(deployment.url, None); +} + +#[tokio::test] +async fn maps_every_provider_state() { + let cases = [ + (json!("QUEUED"), DeploymentStatus::Queued), + (json!("INITIALIZING"), DeploymentStatus::Building), + (json!("BUILDING"), DeploymentStatus::Building), + (json!("READY"), DeploymentStatus::Ready), + (json!("ERROR"), DeploymentStatus::Failed), + (json!("CANCELED"), DeploymentStatus::Canceled), + ( + json!("BLOCKED"), + DeploymentStatus::Other("BLOCKED".to_owned()), + ), + (Value::Null, DeploymentStatus::Queued), + ]; + + for (state, expected) in cases { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v13/deployments/dpl_1", + 200, + json!({"id": "dpl_1", "readyState": state}), + ) + .await; + + let deployment = host(&server).deployment("dpl_1").await.unwrap(); + assert_eq!(deployment.status, expected, "state {state:?}"); + // A response with no name falls back to what the caller asked about. + assert_eq!(deployment.site, ""); + } +} + +#[tokio::test] +async fn lists_deployments_under_their_list_field_names() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v7/deployments")) + .and(query_param("projectId", "shop")) + .and(query_param("limit", "3")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "deployments": [ + {"uid": "dpl_2", "name": "shop", "state": "READY", "created": 2_u64, "url": "b.vercel.app"}, + {"uid": "dpl_1", "name": "shop", "state": "ERROR", "created": 1_u64, "url": "a.vercel.app"}, + ] + }))) + .mount(&server) + .await; + + let deployments = host(&server).list_deployments("shop", 3).await.unwrap(); + + assert_eq!(deployments[0].id, "dpl_2"); + assert_eq!(deployments[0].status, DeploymentStatus::Ready); + assert_eq!(deployments[0].created_at_ms, Some(2)); + assert_eq!(deployments[1].status, DeploymentStatus::Failed); +} + +#[tokio::test] +async fn an_empty_deployment_list_decodes() { + let server = MockServer::start().await; + mount(&server, "GET", "/v7/deployments", 200, json!({})).await; + + assert!( + host(&server) + .list_deployments("shop", 1) + .await + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn promoting_resolves_the_project_first() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v9/projects/shop", + 200, + json!({"id": "prj_1", "name": "shop"}), + ) + .await; + mount( + &server, + "POST", + "/v10/projects/prj_1/promote/dpl_1", + 200, + json!({}), + ) + .await; + + host(&server).promote("shop", "dpl_1").await.unwrap(); +} + +#[tokio::test] +async fn promoting_an_unknown_site_says_which_project_is_missing() { + let server = MockServer::start().await; + mount(&server, "GET", "/v9/projects/ghost", 404, json!({})).await; + + let error = host(&server).promote("ghost", "dpl_1").await.unwrap_err(); + + assert_eq!( + error, + Error::NotFound { + provider: "vercel".to_owned(), + resource: "project ghost".to_owned(), + } + ); +} + +#[tokio::test] +async fn adds_a_domain() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v10/projects/shop/domains")) + .and(body_json(json!({"name": "shop.com"}))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"name": "shop.com", "verified": false})), + ) + .mount(&server) + .await; + + let domain = host(&server) + .add_domain("shop", " shop.com ") + .await + .unwrap(); + + assert_eq!(domain.name, "shop.com"); + assert_eq!(domain.site, "shop"); + assert!(!domain.verified); +} + +#[tokio::test] +async fn a_blank_domain_never_reaches_the_provider() { + let server = MockServer::start().await; + + let error = host(&server).add_domain("shop", " ").await.unwrap_err(); + + assert_eq!(error, Error::EmptyDomain); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn lists_domains() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v9/projects/shop/domains", + 200, + json!({"domains": [{"name": "shop.com", "verified": true}, {"name": "www.shop.com"}]}), + ) + .await; + + let domains = host(&server).list_domains("shop").await.unwrap(); + + assert!(domains[0].verified); + assert!(!domains[1].verified); + assert_eq!(domains[1].site, "shop"); +} + +/// The three responses a database provisioning walks through. +async fn mount_marketplace(server: &MockServer, product: Value, store: Value) { + Mock::given(method("GET")) + .and(path("/v1/integrations/configurations")) + .and(query_param("view", "account")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + {"id": "icfg_logs", "slug": "logtail"}, + {"id": "icfg_db", "slug": "neon"}, + ]))) + .mount(server) + .await; + mount( + server, + "GET", + "/v1/integrations/configuration/icfg_logs/products", + 200, + json!({"products": [ + {"id": "iap_logs", "slug": "log-drain", "name": "Log storage", "primaryProtocol": "logDrain"} + ]}), + ) + .await; + mount( + server, + "GET", + "/v1/integrations/configuration/icfg_db/products", + 200, + json!({"products": [product]}), + ) + .await; + mount( + server, + "POST", + "/v1/storage/stores/integration/direct", + 200, + store, + ) + .await; +} + +#[tokio::test] +async fn provisions_a_postgres_from_an_installed_integration() { + let server = MockServer::start().await; + mount_marketplace( + &server, + json!({ + "id": "iap_serverless", + "slug": "serverless-db", + "name": "Serverless DB", + "primaryProtocol": "storage", + }), + json!({"store": { + "id": "store_1", + "name": "shop-db", + "status": "available", + "secrets": [{"name": "DATABASE_URL", "length": 60}, {"name": "PGHOST", "length": 20}], + "product": { + "slug": "serverless-db", + "name": "Serverless DB", + "integrationConfigurationId": "icfg_db", + }, + }}), + ) + .await; + + let database = host(&server) + .provision_database(&DatabaseSpec::new(" shop-db ")) + .await + .unwrap(); + + assert_eq!(database.id, "store_1"); + assert_eq!(database.name, "shop-db"); + assert_eq!(database.kind, DatabaseKind::Postgres); + assert_eq!(database.product.as_deref(), Some("serverless-db")); + assert_eq!(database.status, "available"); + assert_eq!(database.secret_keys, ["DATABASE_URL", "PGHOST"]); + assert_eq!(database.installation_id.as_deref(), Some("icfg_db")); + + // The installation's own slug is what identified this product as a Postgres: + // neither the product slug nor its name contains the protocol. + let requests = server.received_requests().await.unwrap(); + let created: Value = serde_json::from_slice(&requests.last().unwrap().body).unwrap(); + assert_eq!(created["integrationConfigurationId"], "icfg_db"); + assert_eq!(created["integrationProductIdOrSlug"], "iap_serverless"); + assert_eq!(created["name"], "shop-db"); +} + +#[tokio::test] +async fn a_pinned_product_overrules_the_kind() { + let server = MockServer::start().await; + mount_marketplace( + &server, + json!({"id": "iap_odd", "slug": "mystery-store", "name": "Mystery"}), + json!({"store": {"id": "store_2", "name": "cache", "status": "initializing"}}), + ) + .await; + + let database = host(&server) + .provision_database( + &DatabaseSpec::new("cache") + .with_kind(DatabaseKind::Redis) + .with_product("mystery-store"), + ) + .await + .unwrap(); + + assert_eq!(database.status, "initializing"); + assert_eq!(database.kind, DatabaseKind::Redis); + assert_eq!(database.product, None); + // No product named an installation, so the one it was created through is + // recorded instead — attaching it later needs that identifier. + assert_eq!(database.installation_id.as_deref(), Some("icfg_db")); +} + +#[tokio::test] +async fn a_store_identified_only_by_the_partner_still_has_an_id() { + let server = MockServer::start().await; + mount_marketplace( + &server, + json!({"id": "iap_pg", "slug": "postgres", "primaryProtocol": "storage"}), + json!({"store": {"externalResourceId": "ext_1", "status": "available"}}), + ) + .await; + + let database = host(&server) + .provision_database(&DatabaseSpec::new("shop-db")) + .await + .unwrap(); + + assert_eq!(database.id, "ext_1"); + assert_eq!(database.name, "shop-db"); +} + +#[tokio::test] +async fn no_matching_product_names_the_kind_that_could_not_be_served() { + let server = MockServer::start().await; + mount_marketplace( + &server, + json!({"id": "iap_x", "slug": "sentry", "name": "Errors", "primaryProtocol": "observability"}), + json!({}), + ) + .await; + + let error = host(&server) + .provision_database(&DatabaseSpec::new("shop-db")) + .await + .unwrap_err(); + + assert_eq!( + error, + Error::NoDatabaseProduct { + provider: "vercel".to_owned(), + kind: "postgres".to_owned(), + } + ); +} + +#[tokio::test] +async fn a_pinned_product_that_is_not_installed_is_reported_by_name() { + let server = MockServer::start().await; + mount_marketplace( + &server, + json!({"id": "iap_pg", "slug": "postgres", "primaryProtocol": "storage"}), + json!({}), + ) + .await; + + let error = host(&server) + .provision_database(&DatabaseSpec::new("shop-db").with_product("planetscale")) + .await + .unwrap_err(); + + assert_eq!( + error, + Error::NoDatabaseProduct { + provider: "vercel".to_owned(), + kind: "planetscale".to_owned(), + } + ); +} + +#[tokio::test] +async fn a_database_that_came_up_in_error_is_not_returned_as_working() { + let server = MockServer::start().await; + mount_marketplace( + &server, + json!({"id": "iap_pg", "slug": "postgres", "primaryProtocol": "storage"}), + json!({"store": {"id": "store_3", "name": "shop-db", "status": "error"}}), + ) + .await; + + let error = host(&server) + .provision_database(&DatabaseSpec::new("shop-db")) + .await + .unwrap_err(); + + assert_eq!( + error, + Error::DatabaseNotReady { + name: "shop-db".to_owned(), + status: "error".to_owned(), + } + ); +} + +#[tokio::test] +async fn a_response_with_no_store_is_a_decoding_failure() { + let server = MockServer::start().await; + mount_marketplace( + &server, + json!({"id": "iap_pg", "slug": "postgres", "primaryProtocol": "storage"}), + json!({"store": Value::Null}), + ) + .await; + + let error = host(&server) + .provision_database(&DatabaseSpec::new("shop-db")) + .await + .unwrap_err(); + + assert!(matches!(error, Error::Decode { .. }), "{error:?}"); +} + +#[tokio::test] +async fn a_blank_database_name_never_reaches_the_provider() { + let server = MockServer::start().await; + + let error = host(&server) + .provision_database(&DatabaseSpec::new(" ")) + .await + .unwrap_err(); + + assert_eq!(error, Error::EmptySiteName); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +fn database() -> Database { + Database { + id: "store_1".to_owned(), + name: "shop-db".to_owned(), + kind: DatabaseKind::Postgres, + product: Some("serverless-db".to_owned()), + status: "available".to_owned(), + secret_keys: vec!["DATABASE_URL".to_owned()], + installation_id: Some("icfg_db".to_owned()), + } +} + +#[tokio::test] +async fn attaching_a_database_returns_the_variables_the_site_receives() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v9/projects/shop", + 200, + json!({"id": "prj_1", "name": "shop"}), + ) + .await; + Mock::given(method("POST")) + .and(path( + "/v1/integrations/installations/icfg_db/resources/store_1/connections", + )) + .and(body_json(json!({ + "projectId": "prj_1", + "envVarEnvironments": ["production", "preview", "development"], + }))) + .respond_with(ResponseTemplate::new(201)) + .mount(&server) + .await; + + let keys = host(&server) + .attach_database(&database(), "shop") + .await + .unwrap(); + + assert_eq!(keys, ["DATABASE_URL"]); +} + +#[tokio::test] +async fn a_database_with_no_installation_cannot_be_attached() { + let server = MockServer::start().await; + let mut database = database(); + database.installation_id = None; + + let error = host(&server) + .attach_database(&database, "shop") + .await + .unwrap_err(); + + assert_eq!( + error, + Error::NotFound { + provider: "vercel".to_owned(), + resource: "marketplace installation for database shop-db".to_owned(), + } + ); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn reports_traffic_totals() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/query/web-analytics/visits/count")) + .and(query_param("projectId", "shop")) + .and(query_param("since", "1000")) + .and(query_param("until", "2000")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "version": 1, + "data": {"visitors": 12, "pageviews": 40}, + }))) + .mount(&server) + .await; + + let summary = host(&server) + .analytics(&AnalyticsQuery::new("shop", 1000, 2000)) + .await + .unwrap(); + + assert_eq!(summary.site, "shop"); + assert_eq!(summary.visitors, Some(12)); + assert_eq!(summary.pageviews, Some(40)); + assert_eq!(summary.since_ms, 1000); + assert_eq!(summary.until_ms, 2000); + assert!(summary.breakdown.is_empty()); +} + +#[tokio::test] +async fn reports_a_breakdown_with_whatever_the_provider_counted() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v1/query/web-analytics/visits/count", + 200, + json!({"data": {}}), + ) + .await; + Mock::given(method("GET")) + .and(path("/v1/query/web-analytics/visits/aggregate")) + .and(query_param("by", "country")) + .and(query_param("limit", "2")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": [ + {"country": "NL", "pageviews": 30, "visitors": 9}, + {"country": "DE", "pageviews": 10.5}, + "not a row", + ] + }))) + .mount(&server) + .await; + + let summary = host(&server) + .analytics( + &AnalyticsQuery::new("shop", 1000, 2000) + .with_breakdown(AnalyticsDimension::Country) + .with_limit(2), + ) + .await + .unwrap(); + + assert_eq!(summary.visitors, None); + assert_eq!(summary.breakdown.len(), 2); + assert_eq!(summary.breakdown[0].label, "NL"); + assert!((summary.breakdown[0].metrics["pageviews"] - 30.0).abs() < f64::EPSILON); + assert!((summary.breakdown[0].metrics["visitors"] - 9.0).abs() < f64::EPSILON); + assert!((summary.breakdown[1].metrics["pageviews"] - 10.5).abs() < f64::EPSILON); + assert!(!summary.breakdown[1].metrics.contains_key("visitors")); +} + +#[tokio::test] +async fn a_breakdown_that_is_not_a_list_is_no_breakdown() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v1/query/web-analytics/visits/count", + 200, + json!({"data": {"visitors": 1, "pageviews": 1}}), + ) + .await; + mount( + &server, + "GET", + "/v1/query/web-analytics/visits/aggregate", + 200, + json!({"data": {"unexpected": true}}), + ) + .await; + + let summary = host(&server) + .analytics( + &AnalyticsQuery::new("shop", 1, 2).with_breakdown(AnalyticsDimension::RequestPath), + ) + .await + .unwrap(); + + assert!(summary.breakdown.is_empty()); +} + +#[tokio::test] +async fn an_impossible_window_never_reaches_the_provider() { + let server = MockServer::start().await; + + let error = host(&server) + .analytics(&AnalyticsQuery::new("shop", 2, 1)) + .await + .unwrap_err(); + + assert_eq!(error, Error::InvalidAnalyticsWindow); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn every_failed_status_maps_to_its_own_error() { + let cases = [ + ( + 401, + Error::Unauthorized { + provider: "vercel".to_owned(), + }, + ), + ( + 403, + Error::Forbidden { + provider: "vercel".to_owned(), + resource: "project list".to_owned(), + }, + ), + ( + 404, + Error::NotFound { + provider: "vercel".to_owned(), + resource: "project list".to_owned(), + }, + ), + ( + 429, + Error::RateLimited { + provider: "vercel".to_owned(), + }, + ), + ]; + + for (status, expected) in cases { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v10/projects")) + .respond_with(ResponseTemplate::new(status)) + .mount(&server) + .await; + + assert_eq!(host(&server).list_sites(1).await.unwrap_err(), expected); + } +} + +#[tokio::test] +async fn a_failure_with_no_body_falls_back_to_the_status_text() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v10/projects")) + .respond_with(ResponseTemplate::new(503)) + .mount(&server) + .await; + + let error = host(&server).list_sites(1).await.unwrap_err(); + + assert_eq!( + error, + Error::Api { + provider: "vercel".to_owned(), + status: 503, + resource: "project list".to_owned(), + message: "Service Unavailable".to_owned(), + } + ); +} + +#[tokio::test] +async fn a_failure_that_is_not_json_is_reported_verbatim() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v10/projects")) + .respond_with(ResponseTemplate::new(502).set_body_string("bad gateway")) + .mount(&server) + .await; + + let error = host(&server).list_sites(1).await.unwrap_err(); + + assert_eq!( + error, + Error::Api { + provider: "vercel".to_owned(), + status: 502, + resource: "project list".to_owned(), + message: "bad gateway".to_owned(), + } + ); +} + +#[tokio::test] +async fn a_body_of_the_wrong_shape_is_a_decoding_failure() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v10/projects")) + .respond_with(ResponseTemplate::new(200).set_body_string("not json at all")) + .mount(&server) + .await; + + let error = host(&server).list_sites(1).await.unwrap_err(); + + assert!(matches!(error, Error::Decode { .. }), "{error:?}"); +} + +#[tokio::test] +async fn a_request_that_never_arrives_is_a_transport_failure() { + // Port 1 on the loopback interface refuses connections. + let host = + Vercel::with_base_url(Credentials::new("token").unwrap(), "http://127.0.0.1:1").unwrap(); + + let error = host.list_sites(1).await.unwrap_err(); + + assert!(matches!(error, Error::Transport { .. }), "{error:?}"); +} + +#[tokio::test] +async fn a_team_scope_is_applied_to_every_request() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v10/projects")) + .and(query_param("teamId", "team_abc")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"projects": []}))) + .mount(&server) + .await; + + let host = Vercel::with_base_url( + Credentials::new("token").unwrap().with_team("team_abc"), + // A trailing slash on the root must not become a double slash in a path. + format!("{}/", server.uri()), + ) + .unwrap(); + + assert!(host.list_sites(1).await.unwrap().is_empty()); +} + +#[test] +fn the_client_reports_itself_without_its_token() { + let host = + Vercel::with_base_url(Credentials::new("super-secret").unwrap(), "http://x").unwrap(); + let rendered = format!("{host:?}"); + + assert!(!rendered.contains("super-secret"), "{rendered}"); + assert!(rendered.contains(""), "{rendered}"); + assert_eq!(host.kind(), ProviderKind::Vercel); +} + +#[test] +fn the_digest_is_sha_1() { + // The published SHA-1 of "abc". Vercel's `x-vercel-digest` defines the + // algorithm, so this is a contract, not an implementation detail. + assert_eq!(digest(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d"); +} + +#[test] +fn an_empty_target_list_means_every_environment() { + assert_eq!(env_targets(&[]), ["production", "preview", "development"]); + assert_eq!(env_targets(&[DeploymentTarget::Preview]), ["preview"]); + assert_eq!( + env_targets(&[DeploymentTarget::Production, DeploymentTarget::Production]), + ["production"] + ); +} diff --git a/src/providers/vercel/wire.rs b/src/providers/vercel/wire.rs new file mode 100644 index 0000000..e37fc1b --- /dev/null +++ b/src/providers/vercel/wire.rs @@ -0,0 +1,424 @@ +//! Vercel's own request and response shapes, and their translation into the +//! unified model. +//! +//! Nothing here is public. The point of a separate module is that the provider's +//! vocabulary stops at its edge: `readyState`, `uid`, `icfg_`, `sensitive` and +//! `projectsMetadata` appear in this file and nowhere else in the crate. +//! +//! Response structs are permissive on purpose. Every field this crate does not +//! read is absent, and every field it reads is optional with a mapped default, +//! so a new field in a Vercel response cannot fail a deployment. Identifier +//! fields carry `alias` attributes because Vercel's list and detail endpoints +//! disagree about their names — `uid` against `id`, `state` against `readyState`. + +use serde::{Deserialize, Serialize}; + +use crate::host::types::{ + Database, DatabaseKind, Deployment, DeploymentStatus, DeploymentTarget, Domain, EnvVarRecord, + Framework, Site, +}; + +/// The body of `POST /v11/projects`. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct CreateProject<'a> { + pub(super) name: &'a str, + pub(super) framework: Option<&'a str>, +} + +/// A project, as every project endpoint returns it. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct Project { + pub(super) id: String, + pub(super) name: String, + #[serde(default)] + pub(super) framework: Option, + #[serde(default)] + pub(super) created_at: Option, +} + +impl Project { + /// Translates the project into the unified model. + pub(super) fn into_site(self) -> Site { + Site { + id: self.id, + name: self.name, + framework: self.framework.as_deref().map(framework_of), + created_at_ms: self.created_at, + } + } +} + +/// The body of `GET /v10/projects`. +#[derive(Deserialize)] +pub(super) struct Projects { + #[serde(default)] + pub(super) projects: Vec, +} + +/// One entry of the `files` array of `POST /v13/deployments`. +#[derive(Serialize)] +pub(super) struct UploadedFile { + pub(super) file: String, + pub(super) sha: String, + pub(super) size: usize, +} + +/// The build settings applied to a project on its first deployment. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ProjectSettings<'a> { + pub(super) framework: Option<&'a str>, +} + +/// The body of `POST /v13/deployments`. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct CreateDeployment<'a> { + pub(super) name: &'a str, + pub(super) files: Vec, + /// Omitted for a preview: Vercel reads a missing target as `preview`, and + /// sending the string "preview" is not the same request. + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) target: Option<&'a str>, + pub(super) project_settings: ProjectSettings<'a>, +} + +/// A deployment, as both the create and the list endpoints return it. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct DeploymentBody { + #[serde(alias = "uid")] + pub(super) id: String, + #[serde(default)] + pub(super) name: Option, + #[serde(default)] + pub(super) url: Option, + #[serde(default, alias = "state")] + pub(super) ready_state: Option, + #[serde(default)] + pub(super) target: Option, + #[serde(default, alias = "created")] + pub(super) created_at: Option, + #[serde(default)] + pub(super) error_message: Option, +} + +impl DeploymentBody { + /// Translates the deployment into the unified model. + /// + /// `site` is the name the caller asked about, used when the response omits + /// one. + pub(super) fn into_deployment(self, site: &str) -> Deployment { + Deployment { + id: self.id, + site: self.name.unwrap_or_else(|| site.to_owned()), + // Vercel returns a bare host; every consumer wants a URL it can open. + url: self.url.map(|host| { + if host.starts_with("http://") || host.starts_with("https://") { + host + } else { + format!("https://{host}") + } + }), + status: status_of(self.ready_state.as_deref()), + target: match self.target.as_deref() { + Some("production") => DeploymentTarget::Production, + _ => DeploymentTarget::Preview, + }, + created_at_ms: self.created_at, + error_message: self.error_message, + } + } +} + +/// The body of `GET /v7/deployments`. +#[derive(Deserialize)] +pub(super) struct Deployments { + #[serde(default)] + pub(super) deployments: Vec, +} + +/// One entry of the `POST /v10/projects/{id}/env` array body. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct CreateEnvVar { + pub(super) key: String, + pub(super) value: String, + /// `sensitive` is Vercel's write-only storage; `encrypted` is its default. + pub(super) r#type: &'static str, + pub(super) target: Vec<&'static str>, +} + +/// A target field that Vercel returns as either a string or an array. +#[derive(Deserialize)] +#[serde(untagged)] +pub(super) enum Targets { + One(String), + Many(Vec), +} + +impl Targets { + /// The targets as the unified model's list, dropping `development`, which + /// has no unified equivalent — it is a local environment, not a deployment. + fn into_targets(self) -> Vec { + let names = match self { + Self::One(name) => vec![name], + Self::Many(names) => names, + }; + names + .into_iter() + .filter_map(|name| match name.as_str() { + "production" => Some(DeploymentTarget::Production), + "preview" => Some(DeploymentTarget::Preview), + _ => None, + }) + .collect() + } +} + +/// An environment variable, as `GET /v10/projects/{id}/env` returns it. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct EnvBody { + #[serde(default)] + pub(super) id: Option, + pub(super) key: String, + #[serde(default)] + pub(super) target: Option, + #[serde(default)] + pub(super) r#type: Option, +} + +impl EnvBody { + /// Translates the variable into the unified model, without its value. + pub(super) fn into_record(self) -> EnvVarRecord { + EnvVarRecord { + id: self.id.unwrap_or_default(), + key: self.key, + targets: self.target.map(Targets::into_targets).unwrap_or_default(), + secret: self.r#type.as_deref() == Some("sensitive"), + } + } +} + +/// The body of `GET /v10/projects/{id}/env`. +#[derive(Deserialize)] +pub(super) struct Envs { + #[serde(default)] + pub(super) envs: Vec, +} + +/// The body of `POST /v10/projects/{id}/domains`. +#[derive(Serialize)] +pub(super) struct CreateDomain<'a> { + pub(super) name: &'a str, +} + +/// A project domain. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct DomainBody { + pub(super) name: String, + #[serde(default)] + pub(super) verified: bool, +} + +impl DomainBody { + /// Translates the domain into the unified model. + pub(super) fn into_domain(self, site: &str) -> Domain { + Domain { + name: self.name, + site: site.to_owned(), + verified: self.verified, + } + } +} + +/// The body of `GET /v9/projects/{id}/domains`. +#[derive(Deserialize)] +pub(super) struct Domains { + #[serde(default)] + pub(super) domains: Vec, +} + +/// A marketplace installation on the account. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct Configuration { + pub(super) id: String, + #[serde(default)] + pub(super) slug: Option, +} + +/// A product one installation offers. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct Product { + pub(super) id: String, + pub(super) slug: String, + #[serde(default)] + pub(super) name: Option, + #[serde(default)] + pub(super) primary_protocol: Option, +} + +impl Product { + /// Whether this product can serve `kind`. + /// + /// The match runs over the product's slug and name and the installation's + /// slug, because a vendor's naming rarely contains the protocol its product + /// speaks: the product is `serverless-postgres`, the installation is `neon`, + /// and either one is enough to identify a Postgres. + /// + /// A product that declares a non-storage protocol is excluded outright. An + /// observability integration should not be matched as a database because its + /// name happens to contain "storage". + pub(super) fn serves(&self, kind: &DatabaseKind, installation_slug: Option<&str>) -> bool { + if self + .primary_protocol + .as_deref() + .is_some_and(|protocol| protocol != "storage") + { + return false; + } + + let haystack = format!( + "{} {} {}", + self.slug, + self.name.as_deref().unwrap_or_default(), + installation_slug.unwrap_or_default() + ) + .to_ascii_lowercase(); + + kind.product_hints() + .iter() + .any(|hint| haystack.contains(&hint.to_ascii_lowercase())) + } +} + +/// The body of `GET /v1/integrations/configuration/{id}/products`. +#[derive(Deserialize)] +pub(super) struct Products { + #[serde(default)] + pub(super) products: Vec, +} + +/// The body of `POST /v1/storage/stores/integration/direct`. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct CreateStore<'a> { + pub(super) name: &'a str, + pub(super) integration_configuration_id: &'a str, + pub(super) integration_product_id_or_slug: &'a str, +} + +/// The product a store was created from. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct StoreProduct { + #[serde(default)] + pub(super) slug: Option, + #[serde(default)] + pub(super) name: Option, + #[serde(default)] + pub(super) integration_configuration_id: Option, +} + +/// One secret a connected project receives. Only its name is returned. +#[derive(Deserialize)] +pub(super) struct StoreSecret { + pub(super) name: String, +} + +/// A provisioned store. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct Store { + /// Vercel's own resource identifier, which is what a connection call needs. + #[serde(default)] + pub(super) id: Option, + /// The partner's identifier, present even when `id` is not. + #[serde(default)] + pub(super) external_resource_id: Option, + #[serde(default)] + pub(super) name: Option, + #[serde(default)] + pub(super) status: Option, + #[serde(default)] + pub(super) secrets: Vec, + #[serde(default)] + pub(super) product: Option, +} + +impl Store { + /// Translates the store into the unified model. + /// + /// `id` is preferred over `external_resource_id`: the connection endpoint + /// addresses the resource by Vercel's identifier, and the partner's is only + /// a fallback so an inventory listing is never left without one. + pub(super) fn into_database(self, requested: &str, kind: DatabaseKind) -> Database { + let product = self.product; + Database { + id: self + .id + .or(self.external_resource_id) + .unwrap_or_else(|| requested.to_owned()), + name: self.name.unwrap_or_else(|| requested.to_owned()), + kind, + product: product + .as_ref() + .and_then(|product| product.slug.clone().or_else(|| product.name.clone())), + status: self.status.unwrap_or_else(|| "unknown".to_owned()), + secret_keys: self.secrets.into_iter().map(|secret| secret.name).collect(), + installation_id: product.and_then(|product| product.integration_configuration_id), + } + } +} + +/// The body of `POST /v1/storage/stores/integration/direct`'s response. +#[derive(Deserialize)] +pub(super) struct StoreEnvelope { + #[serde(default)] + pub(super) store: Option, +} + +/// The body of a connection request. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ConnectResource<'a> { + pub(super) project_id: &'a str, + pub(super) env_var_environments: Vec<&'static str>, +} + +/// A web analytics response. The `data` shape depends on the query, so it stays +/// untyped here and is read by the caller that knows which query it sent. +#[derive(Deserialize)] +pub(super) struct AnalyticsEnvelope { + #[serde(default)] + pub(super) data: serde_json::Value, +} + +/// Maps Vercel's `readyState` onto the unified status. +fn status_of(state: Option<&str>) -> DeploymentStatus { + match state { + // A deployment Vercel has not described yet has been accepted but not + // started, which is what queued means. + Some("QUEUED") | None => DeploymentStatus::Queued, + Some("INITIALIZING" | "BUILDING") => DeploymentStatus::Building, + Some("READY") => DeploymentStatus::Ready, + Some("ERROR") => DeploymentStatus::Failed, + Some("CANCELED") => DeploymentStatus::Canceled, + Some(other) => DeploymentStatus::Other(other.to_owned()), + } +} + +/// Maps Vercel's `framework` onto the unified framework. +fn framework_of(name: &str) -> Framework { + match name { + "nextjs" => Framework::NextJs, + "static" => Framework::Static, + other => Framework::Other(other.to_owned()), + } +} diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs new file mode 100644 index 0000000..e7b868c --- /dev/null +++ b/src/rpc/mod.rs @@ -0,0 +1,276 @@ +//! One JSON request in, one JSON result out. +//! +//! Everything above this crate is in another process: `OpenCompany`'s front end +//! takes an API key from a form, `OpenHuman`'s agent decides to ship a site, and +//! neither one links against this library. [`execute_json`] is the boundary they +//! meet at, and the `TinyBus` adapter is a thin wrapper over it. +//! +//! # The credential travels with the request +//! +//! A hosting account belongs to a user, not to the process, so [`Request`] +//! carries the credential rather than reading it from a global. A request that +//! omits it falls back to the environment, which is what a self-hosted single +//! tenant wants. The credential is read out of the envelope and never written +//! back into a result — see [`Credentials`]. + +use serde::{Deserialize, Serialize}; + +use crate::host::types::{ + AnalyticsQuery, AnalyticsSummary, Database, DatabaseSpec, DeployRequest, Deployment, Domain, + EnvVar, EnvVarRecord, Site, SiteSpec, +}; +use crate::launch::types::{Launch, LaunchPlan}; +use crate::providers::{ProviderKind, connect_to}; +use crate::{Credentials, Error, Result}; + +/// A request to act on one hosting account. +#[derive(Debug, Deserialize)] +pub struct Request { + /// Which provider to act on. Defaults to [`ProviderKind::Vercel`]. + #[serde(default)] + pub provider: ProviderKind, + /// The account's credential. Omitted, it is read from the environment. + #[serde(default)] + pub credentials: Option, + /// An alternate API root for the provider. + /// + /// Set it when the provider is reached through an egress proxy. Omitted, the + /// provider's own root is used. + #[serde(default)] + pub base_url: Option, + /// What to do. + #[serde(flatten)] + pub operation: Operation, +} + +/// One thing a request can ask for. +/// +/// The variants are exactly the [`Host`](crate::Host) surface plus +/// [`launch`](crate::launch()), so the bus exposes no more authority than the +/// library does. +#[derive(Debug, Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +#[non_exhaustive] +pub enum Operation { + /// Run a whole launch: site, database, environment, domains, deployment. + Launch { + /// The plan to run. + /// + /// Boxed because it carries a whole bundle: unboxed, every other + /// variant of this enum would be as large as an application. + plan: Box, + }, + /// Create a site. + CreateSite { + /// What the site should be. + spec: SiteSpec, + }, + /// Find a site by name, or report that there is none. + FindSite { + /// The site's name or identifier. + site: String, + }, + /// List sites, newest first. + ListSites { + /// How many to return. + #[serde(default = "default_limit")] + limit: u32, + }, + /// Set environment variables on a site. + SetEnv { + /// The site's name or identifier. + site: String, + /// The variables to set. + vars: Vec, + }, + /// List a site's environment variables, without their values. + ListEnv { + /// The site's name or identifier. + site: String, + }, + /// Provision a managed database. + ProvisionDatabase { + /// What the database should be. + spec: DatabaseSpec, + }, + /// Connect a database to a site. + AttachDatabase { + /// The database, as [`Operation::ProvisionDatabase`] returned it. + database: Database, + /// The site's name or identifier. + site: String, + }, + /// Upload a bundle and start a deployment. + Deploy { + /// The deployment to start. Boxed for the same reason as + /// [`Operation::Launch`]'s plan. + request: Box, + }, + /// Read a deployment's current state. + Deployment { + /// The deployment's identifier. + id: String, + }, + /// List a site's deployments, newest first. + ListDeployments { + /// The site's name or identifier. + site: String, + /// How many to return. + #[serde(default = "default_limit")] + limit: u32, + }, + /// Point production traffic at an existing deployment. + Promote { + /// The site's name or identifier. + site: String, + /// The deployment's identifier. + deployment: String, + }, + /// Add a custom domain to a site. + AddDomain { + /// The site's name or identifier. + site: String, + /// The domain to add. + domain: String, + }, + /// List a site's domains. + ListDomains { + /// The site's name or identifier. + site: String, + }, + /// Report the traffic a site served. + Analytics { + /// The window to report on. + query: AnalyticsQuery, + }, +} + +const fn default_limit() -> u32 { + 20 +} + +/// What an operation produced. +/// +/// The envelope is adjacently tagged — `{"result": "...", "value": ...}` — so a +/// list result and a record result have the same shape on the wire, and a reader +/// can dispatch on one field. +#[derive(Debug, Serialize)] +#[serde(tag = "result", content = "value", rename_all = "snake_case")] +#[non_exhaustive] +pub enum Outcome { + /// A completed launch. + /// + /// Boxed because it carries a whole site, database and deployment: unboxed, + /// every other variant would be as large as the largest one. + Launch(Box), + /// One site. + Site(Site), + /// A site that does not exist. + NoSite, + /// Several sites. + Sites(Vec), + /// One deployment. + Deployment(Deployment), + /// Several deployments. + Deployments(Vec), + /// A site's environment variables, without their values. + Env(Vec), + /// One database. + Database(Database), + /// The environment variable names a database injected. + EnvKeys(Vec), + /// One domain. + Domain(Domain), + /// Several domains. + Domains(Vec), + /// A traffic report. + Analytics(AnalyticsSummary), + /// An operation that produced nothing but succeeded. + Done, +} + +/// Runs one request. +/// +/// # Errors +/// +/// Returns [`Error::MissingApiKey`] when the request carries no credential and +/// the environment holds none, [`Error::UnknownProvider`] when this build has no +/// adapter for the named provider, or whatever the operation itself returns. +pub async fn execute(request: Request) -> Result { + let credentials = match request.credentials { + Some(credentials) => credentials, + None => request.provider.credentials_from_env()?, + }; + let host = connect_to(request.provider, credentials, request.base_url.as_deref())?; + + match request.operation { + Operation::Launch { plan } => crate::launch::launch(host.as_ref(), &plan) + .await + .map(|launched| Outcome::Launch(Box::new(launched))), + Operation::CreateSite { spec } => host.create_site(&spec).await.map(Outcome::Site), + Operation::FindSite { site } => Ok(match host.find_site(&site).await? { + Some(site) => Outcome::Site(site), + None => Outcome::NoSite, + }), + Operation::ListSites { limit } => host.list_sites(limit).await.map(Outcome::Sites), + Operation::SetEnv { site, vars } => { + host.set_env(&site, &vars).await.map(|()| Outcome::Done) + } + Operation::ListEnv { site } => host.list_env(&site).await.map(Outcome::Env), + Operation::ProvisionDatabase { spec } => { + host.provision_database(&spec).await.map(Outcome::Database) + } + Operation::AttachDatabase { database, site } => host + .attach_database(&database, &site) + .await + .map(Outcome::EnvKeys), + Operation::Deploy { request } => host.deploy(&request).await.map(Outcome::Deployment), + Operation::Deployment { id } => host.deployment(&id).await.map(Outcome::Deployment), + Operation::ListDeployments { site, limit } => host + .list_deployments(&site, limit) + .await + .map(Outcome::Deployments), + Operation::Promote { site, deployment } => host + .promote(&site, &deployment) + .await + .map(|()| Outcome::Done), + Operation::AddDomain { site, domain } => { + host.add_domain(&site, &domain).await.map(Outcome::Domain) + } + Operation::ListDomains { site } => host.list_domains(&site).await.map(Outcome::Domains), + Operation::Analytics { query } => host.analytics(&query).await.map(Outcome::Analytics), + } +} + +/// Runs one request given as JSON, returning its result as JSON. +/// +/// # Errors +/// +/// Returns [`Error::Envelope`] when the request is not a [`Request`] or the +/// result cannot be serialized, and otherwise whatever [`execute`] returns. +pub async fn execute_json(request: &str) -> Result { + let request: Request = serde_json::from_str(request).map_err(|error| Error::Envelope { + reason: error.to_string(), + })?; + + let outcome = execute(request).await?; + serde_json::to_string(&outcome).map_err(|error| Error::Envelope { + reason: error.to_string(), + }) +} + +/// The providers this build can connect to, as their slugs. +/// +/// A caller uses this to populate a provider picker without hard-coding what a +/// given build was compiled with. +#[must_use] +pub fn providers() -> Vec<&'static str> { + let mut available = Vec::new(); + if cfg!(feature = "vercel") { + available.push(ProviderKind::Vercel.as_str()); + } + available +} + +#[cfg(test)] +mod test; diff --git a/src/rpc/test.rs b/src/rpc/test.rs new file mode 100644 index 0000000..b2e78e1 --- /dev/null +++ b/src/rpc/test.rs @@ -0,0 +1,440 @@ +//! Tests for the JSON request envelope. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::{Value, json}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use super::*; + +async fn mount(server: &MockServer, verb: &str, route: &str, status: u16, body: Value) { + Mock::given(method(verb)) + .and(path(route.to_owned())) + .respond_with(ResponseTemplate::new(status).set_body_json(body)) + .mount(server) + .await; +} + +/// Runs one operation against a mock provider and returns the parsed result. +async fn run(server: &MockServer, operation: Value) -> Value { + let mut request = operation; + request["provider"] = json!("vercel"); + request["credentials"] = json!({"api_key": "token"}); + request["base_url"] = json!(server.uri()); + + let response = execute_json(&request.to_string()).await.unwrap(); + serde_json::from_str(&response).unwrap() +} + +#[tokio::test] +async fn creates_a_site() { + let server = MockServer::start().await; + mount( + &server, + "POST", + "/v11/projects", + 200, + json!({"id": "prj_1", "name": "shop"}), + ) + .await; + + let result = run( + &server, + json!({"operation": "create_site", "spec": {"name": "shop"}}), + ) + .await; + + assert_eq!(result["result"], "site"); + assert_eq!(result["value"]["id"], "prj_1"); +} + +#[tokio::test] +async fn finds_a_site_or_reports_that_there_is_none() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v9/projects/shop", + 200, + json!({"id": "prj_1", "name": "shop"}), + ) + .await; + mount(&server, "GET", "/v9/projects/ghost", 404, json!({})).await; + + let found = run(&server, json!({"operation": "find_site", "site": "shop"})).await; + assert_eq!(found["result"], "site"); + + let missing = run(&server, json!({"operation": "find_site", "site": "ghost"})).await; + assert_eq!(missing["result"], "no_site"); +} + +#[tokio::test] +async fn lists_sites_with_a_default_limit() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v10/projects")) + .and(wiremock::matchers::query_param("limit", "20")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"projects": [{"id": "prj_1", "name": "shop"}]})), + ) + .mount(&server) + .await; + + let result = run(&server, json!({"operation": "list_sites"})).await; + + assert_eq!(result["result"], "sites"); + assert_eq!(result["value"].as_array().unwrap().len(), 1); +} + +#[tokio::test] +async fn runs_a_whole_launch() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v9/projects/shop", + 200, + json!({"id": "prj_1", "name": "shop"}), + ) + .await; + mount(&server, "POST", "/v2/files", 200, json!({})).await; + mount( + &server, + "POST", + "/v13/deployments", + 200, + json!({"id": "dpl_1", "name": "shop", "url": "shop.vercel.app", "readyState": "QUEUED"}), + ) + .await; + + let result = run( + &server, + json!({ + "operation": "launch", + "plan": { + "site": {"name": "shop"}, + "bundle": [{"path": "package.json", "contents": "e30="}], + }, + }), + ) + .await; + + assert_eq!(result["result"], "launch"); + assert_eq!(result["value"]["deployment"]["id"], "dpl_1"); + assert_eq!(result["value"]["created_site"], false); +} + +#[tokio::test] +async fn deploys_a_bundle() { + let server = MockServer::start().await; + mount(&server, "POST", "/v2/files", 200, json!({})).await; + mount( + &server, + "POST", + "/v13/deployments", + 200, + json!({"id": "dpl_1", "name": "shop", "readyState": "BUILDING"}), + ) + .await; + + let result = run( + &server, + json!({ + "operation": "deploy", + "request": { + "site": "shop", + "bundle": [{"path": "package.json", "contents": "e30="}], + }, + }), + ) + .await; + + assert_eq!(result["result"], "deployment"); + assert_eq!(result["value"]["status"], "building"); +} + +#[tokio::test] +async fn reads_and_lists_deployments() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v13/deployments/dpl_1", + 200, + json!({"id": "dpl_1", "readyState": "READY"}), + ) + .await; + mount( + &server, + "GET", + "/v7/deployments", + 200, + json!({"deployments": [{"uid": "dpl_1", "state": "READY"}]}), + ) + .await; + + let one = run(&server, json!({"operation": "deployment", "id": "dpl_1"})).await; + assert_eq!(one["result"], "deployment"); + + let many = run( + &server, + json!({"operation": "list_deployments", "site": "shop", "limit": 5}), + ) + .await; + assert_eq!(many["result"], "deployments"); + assert_eq!(many["value"].as_array().unwrap().len(), 1); +} + +#[tokio::test] +async fn sets_and_lists_environment_variables() { + let server = MockServer::start().await; + mount( + &server, + "POST", + "/v10/projects/shop/env", + 201, + json!({"failed": []}), + ) + .await; + mount( + &server, + "GET", + "/v10/projects/shop/env", + 200, + json!({"envs": [{"id": "env_1", "key": "K", "target": ["production"]}]}), + ) + .await; + + let set = run( + &server, + json!({ + "operation": "set_env", + "site": "shop", + "vars": [{"key": "K", "value": "V"}], + }), + ) + .await; + assert_eq!(set["result"], "done"); + + let listed = run(&server, json!({"operation": "list_env", "site": "shop"})).await; + assert_eq!(listed["result"], "env"); + assert_eq!(listed["value"][0]["key"], "K"); +} + +#[tokio::test] +async fn provisions_and_attaches_a_database() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v1/integrations/configurations", + 200, + json!([{"id": "icfg_db", "slug": "neon"}]), + ) + .await; + mount( + &server, + "GET", + "/v1/integrations/configuration/icfg_db/products", + 200, + json!({"products": [{"id": "iap_pg", "slug": "postgres", "primaryProtocol": "storage"}]}), + ) + .await; + mount( + &server, + "POST", + "/v1/storage/stores/integration/direct", + 200, + json!({"store": { + "id": "store_1", + "name": "shop-db", + "status": "available", + "secrets": [{"name": "DATABASE_URL", "length": 1}], + "product": {"slug": "postgres", "integrationConfigurationId": "icfg_db"}, + }}), + ) + .await; + mount( + &server, + "GET", + "/v9/projects/shop", + 200, + json!({"id": "prj_1", "name": "shop"}), + ) + .await; + mount( + &server, + "POST", + "/v1/integrations/installations/icfg_db/resources/store_1/connections", + 201, + json!({}), + ) + .await; + + let provisioned = run( + &server, + json!({"operation": "provision_database", "spec": {"name": "shop-db"}}), + ) + .await; + assert_eq!(provisioned["result"], "database"); + + let attached = run( + &server, + json!({ + "operation": "attach_database", + "site": "shop", + "database": provisioned["value"], + }), + ) + .await; + assert_eq!(attached["result"], "env_keys"); + assert_eq!(attached["value"][0], "DATABASE_URL"); +} + +#[tokio::test] +async fn promotes_a_deployment() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v9/projects/shop", + 200, + json!({"id": "prj_1", "name": "shop"}), + ) + .await; + mount( + &server, + "POST", + "/v10/projects/prj_1/promote/dpl_1", + 200, + json!({}), + ) + .await; + + let result = run( + &server, + json!({"operation": "promote", "site": "shop", "deployment": "dpl_1"}), + ) + .await; + + assert_eq!(result["result"], "done"); +} + +#[tokio::test] +async fn adds_and_lists_domains() { + let server = MockServer::start().await; + mount( + &server, + "POST", + "/v10/projects/shop/domains", + 200, + json!({"name": "shop.com", "verified": true}), + ) + .await; + mount( + &server, + "GET", + "/v9/projects/shop/domains", + 200, + json!({"domains": [{"name": "shop.com", "verified": true}]}), + ) + .await; + + let added = run( + &server, + json!({"operation": "add_domain", "site": "shop", "domain": "shop.com"}), + ) + .await; + assert_eq!(added["result"], "domain"); + + let listed = run( + &server, + json!({"operation": "list_domains", "site": "shop"}), + ) + .await; + assert_eq!(listed["result"], "domains"); +} + +#[tokio::test] +async fn reports_analytics() { + let server = MockServer::start().await; + mount( + &server, + "GET", + "/v1/query/web-analytics/visits/count", + 200, + json!({"data": {"visitors": 3, "pageviews": 9}}), + ) + .await; + + let result = run( + &server, + json!({ + "operation": "analytics", + "query": {"site": "shop", "since_ms": 1, "until_ms": 2}, + }), + ) + .await; + + assert_eq!(result["result"], "analytics"); + assert_eq!(result["value"]["pageviews"], 9); +} + +#[tokio::test] +async fn a_request_that_is_not_an_envelope_is_rejected_as_one() { + let error = execute_json("{ not json }").await.unwrap_err(); + + assert!(matches!(error, Error::Envelope { .. }), "{error:?}"); +} + +#[tokio::test] +async fn an_unknown_operation_is_rejected() { + let error = execute_json(r#"{"operation":"delete_everything"}"#) + .await + .unwrap_err(); + + assert!(matches!(error, Error::Envelope { .. }), "{error:?}"); +} + +#[tokio::test] +async fn a_provider_error_reaches_the_caller_unchanged() { + let server = MockServer::start().await; + mount(&server, "GET", "/v10/projects", 401, json!({})).await; + + let request = json!({ + "operation": "list_sites", + "credentials": {"api_key": "token"}, + "base_url": server.uri(), + }); + let error = execute_json(&request.to_string()).await.unwrap_err(); + + assert_eq!( + error, + Error::Unauthorized { + provider: "vercel".to_owned() + } + ); +} + +#[tokio::test] +async fn a_request_without_a_credential_falls_back_to_the_environment() { + // Whether the environment holds a key depends on the machine; both outcomes + // are correct, and running the call is what exercises the fallback. + let result = + execute_json(r#"{"operation":"list_sites","base_url":"http://127.0.0.1:1"}"#).await; + + match result { + Ok(_) => panic!("port 1 cannot have answered"), + Err(error) => assert!( + matches!(error, Error::MissingApiKey { .. } | Error::Transport { .. }), + "{error:?}" + ), + } +} + +#[test] +fn the_available_providers_are_listed() { + assert_eq!(providers(), ["vercel"]); +} diff --git a/src/tinybus_module/README.md b/src/tinybus_module/README.md index 1cece84..ae5efc7 100644 --- a/src/tinybus_module/README.md +++ b/src/tinybus_module/README.md @@ -1,17 +1,23 @@ # TinyBus Adapter -This module is the boundary between ordinary feature code and TinyBus module -ABI v1. `GreetingService` converts the crate's public `greet` function into the -typed `Greet` bus method, while `setup` registers its object and claims the -well-known interface name. +This module is the boundary between the hosting library and TinyBus module ABI +v1. `HostingService` exposes two methods: `Execute`, which takes one JSON +hosting request and returns one JSON result, and `Providers`, which reports the +provider slugs this build was compiled with. `setup` registers the object and +claims the well-known interface name. + +Both methods delegate straight into `crate::rpc`. `Execute` is one method rather +than one per operation because the JSON envelope is the contract either way — +the front end that calls this does not link against the crate — and fourteen bus +signatures would have to be kept in step with the Rust API, the envelope, and +the manifest at once. `tinybus_module::module_export!` emits the descriptor, embedded manifest, and initialization symbols consumed by the dynamic loader. The manifest method list -must stay aligned with the interface macro's dispatch table; the unit test -checks that relationship. Integration tests use TinyBus's in-memory transport, -and `examples/verify_module.rs` loads a compiled `cdylib` through the real -dynamic loader before a release archive is accepted. +must stay aligned with the interface macro's dispatch table; the unit test checks +that relationship. Integration tests use TinyBus's in-memory transport against a +local mock of the provider API, and `examples/verify_module.rs` loads a compiled +`cdylib` through the real dynamic loader before a release archive is accepted. -Generated projects should replace the example interface, object path, and -method declarations together. They must not retain Rust-owned data across the -ABI boundary or bypass the SDK exports with an ad hoc FFI surface. +This module is handed live hosting credentials by its callers. It must not log a +request, cache one, or retain Rust-owned data across the ABI boundary. diff --git a/src/tinybus_module/mod.rs b/src/tinybus_module/mod.rs index 19feda1..2d26b21 100644 --- a/src/tinybus_module/mod.rs +++ b/src/tinybus_module/mod.rs @@ -1,19 +1,36 @@ //! `TinyBus` module entrypoint and bus-facing interface. //! -//! This adapter keeps the feature implementation independent from `TinyBus` while -//! exposing it as an installable, dynamically loaded integration. +//! This adapter keeps the hosting implementation independent from `TinyBus` while +//! exposing it as an installable, dynamically loaded integration. It is +//! deliberately thin: the two methods are the two things a bus caller needs, and +//! both delegate straight into [`crate::rpc`]. +//! +//! `Execute` takes one JSON request and returns one JSON result rather than +//! mirroring each of the fourteen operations as its own bus method. The +//! operations share a credential, a provider and an error vocabulary, and +//! fourteen signatures would have to be kept in step with the Rust API, the +//! JSON envelope, and the manifest at once. The JSON envelope is the contract +//! either way — the front end that calls this does not link against the crate. use tinybus::{Connection, Result as TinyBusResult}; -const INTERFACE: &str = "ai.tinyhumans.rust_template.Greeting"; -const OBJECT_PATH: &str = "/ai/tinyhumans/rust_template/Greeting"; +const INTERFACE: &str = "ai.tinyhumans.tinyhosts.Hosting"; +const OBJECT_PATH: &str = "/ai/tinyhumans/tinyhosts/Hosting"; + +struct HostingService; -struct GreetingService; +#[tinybus::interface(name = "ai.tinyhumans.tinyhosts.Hosting")] +impl HostingService { + /// Runs one JSON hosting request and returns its JSON result. + async fn execute(&self, request: String) -> TinyBusResult { + crate::rpc::execute_json(&request) + .await + .map_err(|error| tinybus::Error::failed(error.to_string())) + } -#[tinybus::interface(name = "ai.tinyhumans.rust_template.Greeting")] -impl GreetingService { - async fn greet(&self, name: String) -> TinyBusResult { - std::future::ready(crate::greet(&name)) + /// Lists the provider slugs this build can connect to. + async fn providers(&self) -> TinyBusResult { + std::future::ready(serde_json::to_string(&crate::rpc::providers())) .await .map_err(|error| tinybus::Error::failed(error.to_string())) } @@ -21,7 +38,7 @@ impl GreetingService { async fn setup(connection: Connection) -> TinyBusResult<()> { connection - .serve_at(OBJECT_PATH.try_into()?, GreetingService) + .serve_at(OBJECT_PATH.try_into()?, HostingService) .await?; connection.request_name(INTERFACE).await?; Ok(()) @@ -29,9 +46,9 @@ async fn setup(connection: Connection) -> TinyBusResult<()> { tinybus_module::module_export! { setup = setup, - worker_threads = 1, - provides = ["ai.tinyhumans.rust_template.Greeting"], - methods = ["Greet"], + worker_threads = 2, + provides = ["ai.tinyhumans.tinyhosts.Hosting"], + methods = ["Execute", "Providers"], signals = [], requires = [], optional = [], diff --git a/src/tinybus_module/test.rs b/src/tinybus_module/test.rs index c869197..9d7fdb8 100644 --- a/src/tinybus_module/test.rs +++ b/src/tinybus_module/test.rs @@ -1,54 +1,100 @@ //! Tests for the `TinyBus` module adapter and its declared surface. -use super::{GreetingService, INTERFACE, OBJECT_PATH, setup}; +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::json; use tinybus::broker::Broker; use tinybus::transport::memory::MemoryBus; use tinybus::{Connection, Interface}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use super::{HostingService, INTERFACE, OBJECT_PATH, setup}; + +/// A client connected to a bus with the module already serving on it. +/// +/// The service connection is returned alongside the client: dropping it drops +/// the name it claimed, and every call would then fail with `NameHasNoOwner`. +async fn connect() -> tinybus::Result<(Connection, Connection)> { + let bus = MemoryBus::new(); + Broker::new().spawn(bus.clone()); + + let service = Connection::connect(bus.connect().await?).await?; + setup(service.clone()).await?; + + let client = Connection::connect(bus.connect().await?).await?; + Ok((service, client)) +} #[test] fn declared_methods_match_the_dispatch_table() { - let methods = GreetingService + let methods = HostingService .members() .into_iter() .map(|member| member.to_string()) .collect::>(); - assert_eq!(methods, ["Greet"]); + assert_eq!(methods, ["Execute", "Providers"]); } #[tokio::test] -async fn module_serves_greetings_over_a_real_bus() -> tinybus::Result<()> { - let bus = MemoryBus::new(); - Broker::new().spawn(bus.clone()); - - let service = Connection::connect(bus.connect().await?).await?; - setup(service.clone()).await?; - - let client = Connection::connect(bus.connect().await?).await?; +async fn the_module_reports_the_providers_this_build_has() -> tinybus::Result<()> { + let (_service, client) = connect().await?; let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; - let greeting: String = proxy.call("Greet", ("Ferris",)).await?; - assert_eq!(greeting, "Hello, Ferris!"); + let providers: String = proxy.call("Providers", ()).await?; + + assert_eq!(providers, r#"["vercel"]"#); Ok(()) } #[tokio::test] -async fn module_rejects_an_empty_name_over_the_bus() -> tinybus::Result<()> { - let bus = MemoryBus::new(); - Broker::new().spawn(bus.clone()); +async fn the_module_runs_a_hosting_request() -> tinybus::Result<()> { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v10/projects")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"projects": [{"id": "prj_1", "name": "shop"}]})), + ) + .mount(&server) + .await; - let service = Connection::connect(bus.connect().await?).await?; - setup(service.clone()).await?; + let (_service, client) = connect().await?; + let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; - let client = Connection::connect(bus.connect().await?).await?; + let request = json!({ + "operation": "list_sites", + "credentials": {"api_key": "token"}, + "base_url": server.uri(), + }) + .to_string(); + let response: String = proxy.call("Execute", (request,)).await?; + + assert!(response.contains(r#""result":"sites""#), "{response}"); + assert!(response.contains("prj_1"), "{response}"); + Ok(()) +} + +#[tokio::test] +async fn a_failed_request_becomes_a_bus_error_carrying_the_reason() -> tinybus::Result<()> { + let (_service, client) = connect().await?; let proxy = client.proxy(INTERFACE, OBJECT_PATH, INTERFACE)?; - let result = proxy.call::("Greet", (" ",)).await; + + let result = proxy + .call::("Execute", ("{ not an envelope }".to_owned(),)) + .await; let Err(error) = result else { return Err(tinybus::Error::failed( - "whitespace-only names unexpectedly succeeded", + "a malformed envelope unexpectedly succeeded", )); }; - assert!(error.to_string().contains("name must not be empty")); + assert!( + error + .to_string() + .contains("cannot decode the request envelope"), + "{error}" + ); Ok(()) } diff --git a/tests/public_api.rs b/tests/public_api.rs index 4ee1e4b..549e07e 100644 --- a/tests/public_api.rs +++ b/tests/public_api.rs @@ -7,14 +7,116 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -use rust_template::{Error, greet}; +use tinyhosts::{ + Bundle, Credentials, DatabaseKind, DatabaseSpec, DeployRequest, DeploymentTarget, EnvVar, + Error, Framework, LaunchPlan, ProviderKind, SiteSpec, +}; + +fn bundle() -> Bundle { + let mut bundle = Bundle::new(); + bundle + .insert("package.json", br#"{"name":"shop"}"#.to_vec()) + .unwrap(); + bundle +} + +#[test] +fn a_consumer_can_describe_a_whole_launch() { + let plan = LaunchPlan::new(SiteSpec::new("shop"), bundle()) + .with_database(DatabaseSpec::new("shop-db").with_kind(DatabaseKind::Postgres)) + .with_env(vec![EnvVar::new("NEXT_PUBLIC_NAME", "Shop").secret()]) + .with_domains(vec!["shop.example".to_owned()]) + .into_production(); + + assert!(plan.validate().is_ok()); + assert_eq!(plan.target, DeploymentTarget::Production); + assert_eq!(plan.site.framework, Framework::NextJs); +} + +#[test] +fn a_consumer_can_connect_to_a_provider() { + let host = tinyhosts::connect( + ProviderKind::Vercel, + Credentials::new("token").unwrap().with_team("team_abc"), + ) + .unwrap(); + + assert_eq!(host.kind(), ProviderKind::Vercel); +} + +#[test] +fn a_consumer_can_reach_a_provider_through_a_proxy() { + let host = tinyhosts::connect_to( + ProviderKind::Vercel, + Credentials::new("token").unwrap(), + Some("https://vercel.internal.example"), + ) + .unwrap(); + + assert_eq!(host.kind(), ProviderKind::Vercel); +} + +#[test] +fn failures_are_reported_as_typed_errors() { + assert_eq!(Credentials::new(" ").unwrap_err(), Error::EmptyApiKey); + assert_eq!( + DeployRequest::new("shop", Bundle::new()) + .validate() + .unwrap_err(), + Error::EmptyBundle + ); + assert_eq!( + SiteSpec::new(" ").validate().unwrap_err(), + Error::EmptySiteName + ); +} + +#[test] +fn a_credential_is_never_rendered() { + let credentials = Credentials::new("super-secret").unwrap(); + + assert!(!format!("{credentials:?}").contains("super-secret")); +} #[test] -fn greeting_is_available_to_consumers() { - assert_eq!(greet("Rust").unwrap(), "Hello, Rust!"); +fn a_bundle_reads_a_directory_and_skips_what_a_build_produces() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("package.json"), b"{}").unwrap(); + std::fs::create_dir(root.path().join("node_modules")).unwrap(); + std::fs::write(root.path().join("node_modules/x.js"), b"x").unwrap(); + + let bundle = Bundle::from_dir(root.path()).unwrap(); + + assert_eq!(bundle.len(), 1); + assert_eq!(bundle.files()[0].path, "package.json"); + assert!(tinyhosts::EXCLUDED.contains(&"node_modules")); +} + +#[tokio::test] +async fn a_json_request_reaches_the_same_surface() { + // Port 1 refuses connections, so this exercises the envelope and the + // dispatch without depending on a provider being reachable. + let request = serde_json::json!({ + "operation": "list_sites", + "credentials": {"api_key": "token"}, + "base_url": "http://127.0.0.1:1", + }); + + let error = tinyhosts::execute_json(&request.to_string()) + .await + .unwrap_err(); + + assert!(matches!(error, Error::Transport { .. }), "{error:?}"); +} + +#[tokio::test] +async fn a_malformed_json_request_is_rejected_as_an_envelope() { + let error = tinyhosts::execute_json("not json").await.unwrap_err(); + + assert!(matches!(error, Error::Envelope { .. }), "{error:?}"); } #[test] -fn errors_are_available_to_consumers() { - assert_eq!(greet("").unwrap_err(), Error::EmptyName); +fn the_build_reports_which_providers_it_has() { + assert!(tinyhosts::rpc::providers().contains(&"vercel")); } From 1531ee69a6c0d9bda5c8891bea03a0ff538c6adc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 18 Aug 2026 01:52:57 +0300 Subject: [PATCH 2/6] Make the TinyBus module an optional feature Co-authored-by: Medulla --- Cargo.toml | 25 ++++++++++++++++++++++--- README.md | 9 +++++++++ src/lib.rs | 1 + 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0ff61ff..1270762 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,10 +30,10 @@ crate-type = ["rlib", "cdylib"] [dependencies] # TinyBus defines the message types, interface macro, and frozen module ABI used # by the generated integration. Socket and CLI features are unnecessary here. -tinybus = { path = "vendor/tinybus/crates/tinybus", version = "0.1.0", default-features = false, features = ["macros", "modules"] } +tinybus = { path = "vendor/tinybus/crates/tinybus", version = "0.1.0", default-features = false, features = ["macros", "modules"], optional = true } # The module-side SDK owns the isolated runtime and exports the ABI entrypoints # required by TinyBus's dynamic loader. -tinybus-module = { path = "vendor/tinybus/crates/tinybus-module", version = "0.1.0" } +tinybus-module = { path = "vendor/tinybus/crates/tinybus-module", version = "0.1.0", optional = true } # Derive macros for the crate-wide error type in `src/error/mod.rs`. thiserror = "2" # `Host` is consumed as `dyn Host`, and every method performs network I/O, so @@ -57,6 +57,16 @@ sha1 = { version = "0.10", optional = true } # Renders the SHA-1 digest as the lowercase hex string the upload header wants. hex = { version = "0.4", optional = true } +# The two module-verification examples load a compiled `cdylib` through TinyBus's +# own loader, so they only build when the module is in the build. +[[example]] +name = "verify_module" +required-features = ["module"] + +[[example]] +name = "verify_github_release" +required-features = ["module"] + [dev-dependencies] # Module integration tests exercise the real asynchronous in-memory TinyBus. tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } @@ -67,10 +77,19 @@ wiremock = "0.6" tempfile = "3" [features] -default = ["vercel"] +default = ["vercel", "module"] # The Vercel provider and the HTTP stack it needs. Off, the crate is the # provider-agnostic model alone: `Host`, its types, `Bundle`, and `launch`. vercel = ["dep:reqwest", "dep:sha1", "dep:hex"] +# The TinyBus module: the bus interface, the ABI exports, and the `cdylib` a +# TinyBus host loads. On by default, because the installable module is how this +# crate ships. +# +# A downstream that links the library directly — OpenHuman does — takes +# `default-features = false, features = ["vercel"]` and gets no TinyBus at all. +# That is not only a smaller graph: OpenHuman vendors its own TinyBus, and two +# path copies of one package cannot both be written to a lockfile. +module = ["dep:tinybus", "dep:tinybus-module"] # Lints apply to the whole crate and to every target. CI runs clippy with # `-D warnings`, so anything set to "warn" here fails the build in CI. diff --git a/README.md b/README.md index e6c8696..6bce883 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,15 @@ failing later with a missing `DATABASE_URL`. Pin a specific product with `DatabaseSpec::with_product` when an account has more than one that would match. +## Features + +`vercel` (default) is the provider. `module` (default) is the TinyBus module — +the bus interface, the ABI exports and the `cdylib` a TinyBus host loads. A +downstream that links the library directly takes +`default-features = false, features = ["vercel"]` and gets no TinyBus in its +graph, which is what OpenHuman does: it vendors its own TinyBus, and two path +copies of one package cannot both be written to a lockfile. + ## Adding a provider Implement `Host`, add a `ProviderKind` variant, and wire it into `connect_to`. diff --git a/src/lib.rs b/src/lib.rs index 2ec5d57..847d877 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,6 +57,7 @@ pub mod launch; pub mod providers; pub mod rpc; +#[cfg(feature = "module")] mod tinybus_module; pub use bundle::{Bundle, EXCLUDED, SiteFile}; From bafdc6744bf454ba4d0d313300201f9b216d55e7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 18 Aug 2026 03:09:31 +0300 Subject: [PATCH 3/6] feat(docs): add unified hosting API specification Add a new specification document for the unified hosting API, which defines the interface for deploying and managing applications across different hosting providers. This document serves as a reference for implementing the hosting abstraction layer in the bundle module. Auto-committed-on: dragonfly Co-authored-by: Medulla --- README.md | 6 ++-- docs/specs/unified-hosting-api.md | 11 ++++--- src/bundle/mod.rs | 55 +++++++++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 6bce883..fc6bfe5 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,9 @@ The unit of work is the whole thing, because that is what "host this" means: | Deployment | `deploy`, `deployment`, `list_deployments`, `promote` | file upload, then a build | | Traffic | `analytics` | the web analytics query API | -[`launch`](src/launch/mod.rs) runs all six in the one order that works — the -database is connected *before* the build, because a Next.js build reads its -environment at build time. +[`launch`](src/launch/mod.rs) runs the five hosting steps in the one order that +works — the database is connected *before* the build, because a Next.js build +reads its environment at build time. ## Using it diff --git a/docs/specs/unified-hosting-api.md b/docs/specs/unified-hosting-api.md index f018166..5f946ad 100644 --- a/docs/specs/unified-hosting-api.md +++ b/docs/specs/unified-hosting-api.md @@ -17,11 +17,12 @@ Shipping an application is six things, not one: 5. a **deployment** that builds and serves it; 6. the **traffic** it served afterwards. -Every provider does all six, and no two name them the same. A caller that -integrates against one provider's API has hard-coded not just its endpoints but -its shape — Vercel's "project" against Netlify's "site", Vercel's marketplace -store against Railway's first-party Postgres. The unified model is the smallest -vocabulary all of them can be said in. +Providers expose different subsets of these six concerns, and no two name them +the same. Unsupported capabilities must return `Error::Unsupported`. A caller +that integrates against one provider's API has hard-coded not just its +endpoints but its shape — Vercel's "project" against Netlify's "site", Vercel's +marketplace store against Railway's first-party Postgres. The unified model is +the smallest vocabulary all of them can be said in. ## The model diff --git a/src/bundle/mod.rs b/src/bundle/mod.rs index f5935c9..5a74204 100644 --- a/src/bundle/mod.rs +++ b/src/bundle/mod.rs @@ -40,17 +40,55 @@ pub const EXCLUDED: &[&str] = &[ ".DS_Store", ".env", ".env.local", + ".env.development", + ".env.development.local", + ".env.production", + ".env.production.local", + ".env.test", + ".env.test.local", ]; /// One file in a deployment. +/// +/// The fields are private and validated at construction: a `SiteFile` cannot +/// hold a path that is blank, absolute, or climbs out of the bundle, whether it +/// is built through [`SiteFile::new`] or read back from a deserialized +/// [`Bundle`]. A public `path` field would let a caller — or a JSON payload +/// crossing the RPC boundary — set one directly and skip that check. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "RawSiteFile", into = "RawSiteFile")] pub struct SiteFile { + path: String, + contents: Vec, +} + +/// The wire shape of [`SiteFile`], validated through [`SiteFile::new`] on the +/// way in. +#[derive(Serialize, Deserialize)] +struct RawSiteFile { /// The path the file takes inside the deployment, relative and slash /// separated. - pub path: String, + path: String, /// The file's bytes, carried as base64 in serialized form. #[serde(with = "base64_bytes")] - pub contents: Vec, + contents: Vec, +} + +impl TryFrom for SiteFile { + type Error = Error; + + fn try_from(raw: RawSiteFile) -> Result { + Self::new(raw.path, raw.contents) + } +} + +impl From for RawSiteFile { + fn from(file: SiteFile) -> Self { + Self { + path: file.path, + contents: file.contents, + } + } } impl SiteFile { @@ -81,6 +119,19 @@ impl SiteFile { }) } + /// The path the file takes inside the deployment, relative and slash + /// separated. + #[must_use] + pub fn path(&self) -> &str { + &self.path + } + + /// The file's bytes. + #[must_use] + pub fn contents(&self) -> &[u8] { + &self.contents + } + /// The file's size in bytes. #[must_use] pub fn len(&self) -> usize { From 6f299e67ff1d56f854ae67331d466455aa1f0943 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 18 Aug 2026 03:11:43 +0300 Subject: [PATCH 4/6] feat(vercel): add Vercel provider support for bundle operations Introduce a new Vercel provider that enables bundling and deployment of serverless functions. This includes HTTP client configuration, type definitions for Vercel-specific resources, and integration tests to validate the provider's behavior against the public API. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.toml | 6 +++++- src/bundle/mod.rs | 6 ++++-- src/host/test.rs | 10 ++++++++++ src/host/types.rs | 20 +++++++++++++++++++- src/providers/vercel/http.rs | 14 ++++++++++++++ src/providers/vercel/mod.rs | 35 ++++++++++++++++++++++++++--------- src/providers/vercel/test.rs | 10 ++++++++++ tests/public_api.rs | 2 +- 8 files changed, 89 insertions(+), 14 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1270762..4d19af0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,10 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus sha1 = { version = "0.10", optional = true } # Renders the SHA-1 digest as the lowercase hex string the upload header wants. hex = { version = "0.4", optional = true } +# Percent-encodes an RPC-supplied identifier (site name, deployment id, domain) +# before it is interpolated into a Vercel API path, so `/`, `?` and `..` in one +# cannot redirect the request to a different route. +percent-encoding = { version = "2", optional = true } # The two module-verification examples load a compiled `cdylib` through TinyBus's # own loader, so they only build when the module is in the build. @@ -80,7 +84,7 @@ tempfile = "3" default = ["vercel", "module"] # The Vercel provider and the HTTP stack it needs. Off, the crate is the # provider-agnostic model alone: `Host`, its types, `Bundle`, and `launch`. -vercel = ["dep:reqwest", "dep:sha1", "dep:hex"] +vercel = ["dep:reqwest", "dep:sha1", "dep:hex", "dep:percent-encoding"] # The TinyBus module: the bus interface, the ABI exports, and the `cdylib` a # TinyBus host loads. On by default, because the installable module is how this # crate ships. diff --git a/src/bundle/mod.rs b/src/bundle/mod.rs index 5a74204..26d063d 100644 --- a/src/bundle/mod.rs +++ b/src/bundle/mod.rs @@ -244,8 +244,10 @@ impl Bundle { impl TryFrom> for Bundle { type Error = Error; - /// Revalidates every path, because a deserialized bundle did not go through - /// [`SiteFile::new`]. + /// Collapses any duplicate path to its last occurrence, the same rule + /// [`Bundle::insert`] applies one file at a time. Each `SiteFile` already + /// carries a validated path, so this only re-runs [`SiteFile::new`]'s cheap + /// normalization, not the check itself. fn try_from(files: Vec) -> Result { let mut bundle = Self::new(); for file in files { diff --git a/src/host/test.rs b/src/host/test.rs index 16925ef..e697380 100644 --- a/src/host/test.rs +++ b/src/host/test.rs @@ -130,6 +130,16 @@ fn a_blank_env_key_is_rejected() { ); } +#[test] +fn debug_prints_the_key_and_never_the_value() { + let var = EnvVar::new("STRIPE_KEY", "sk_live_hunter2"); + let rendered = format!("{var:?}"); + + assert!(rendered.contains("STRIPE_KEY"), "{rendered}"); + assert!(!rendered.contains("sk_live_hunter2"), "{rendered}"); + assert!(rendered.contains(""), "{rendered}"); +} + #[test] fn a_database_defaults_to_postgres() { let spec = DatabaseSpec::new("shop-db"); diff --git a/src/host/types.rs b/src/host/types.rs index 676e736..9b8ce6a 100644 --- a/src/host/types.rs +++ b/src/host/types.rs @@ -255,7 +255,7 @@ pub struct Deployment { /// The value is write-only across this API: it goes out in a request and is /// never returned, because a provider that hands back decrypted secrets on a /// list call is a provider this crate would be leaking through. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct EnvVar { /// The variable's name. pub key: String, @@ -269,6 +269,24 @@ pub struct EnvVar { pub secret: bool, } +/// Prints the key and targets, never the value. +/// +/// `EnvVar` reaches [`Operation::SetEnv`](crate::rpc::Operation::SetEnv) and +/// [`LaunchPlan`](crate::launch::types::LaunchPlan), both of which derive +/// `Debug`; a derived `Debug` here would put a secret's plaintext value in +/// whatever log line renders one of those. +impl std::fmt::Debug for EnvVar { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("EnvVar") + .field("key", &self.key) + .field("value", &"") + .field("targets", &self.targets) + .field("secret", &self.secret) + .finish() + } +} + impl EnvVar { /// A variable set in every environment. #[must_use] diff --git a/src/providers/vercel/http.rs b/src/providers/vercel/http.rs index 83f3691..8c29c36 100644 --- a/src/providers/vercel/http.rs +++ b/src/providers/vercel/http.rs @@ -7,6 +7,7 @@ //! missing resource in another. use std::fmt; +use std::time::Duration; use reqwest::{Client, Method, RequestBuilder, Response, StatusCode}; use serde::Serialize; @@ -18,6 +19,17 @@ use crate::{Credentials, Error, Result}; /// Vercel's public API root. pub(crate) const DEFAULT_BASE_URL: &str = "https://api.vercel.com"; +/// How long a TCP handshake may take before this crate gives up on it. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); + +/// How long one request may run, upload included. +/// +/// Vercel's own guidance and this crate's own docs agree a bundle is the +/// largest payload it moves, so this is generous rather than tight: a +/// `reqwest` client otherwise has no timeout at all, which turns a stalled +/// connection into a hang this crate's caller can never see or cancel. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(300); + /// An authenticated Vercel API client. pub(crate) struct Http { client: Client, @@ -49,6 +61,8 @@ impl Http { pub(crate) fn new(credentials: Credentials, base_url: impl Into) -> Result { let client = Client::builder() .user_agent(concat!("tinyhosts/", env!("CARGO_PKG_VERSION"))) + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) .build() .map_err(|error| Error::Transport { provider: provider(), diff --git a/src/providers/vercel/mod.rs b/src/providers/vercel/mod.rs index 597449f..67ea9ed 100644 --- a/src/providers/vercel/mod.rs +++ b/src/providers/vercel/mod.rs @@ -344,10 +344,10 @@ impl Host for Vercel { let mut files = Vec::with_capacity(request.bundle.len()); for file in request.bundle.files() { - let sha = digest(&file.contents); - self.upload(&sha, &file.contents).await?; + let sha = digest(file.contents()); + self.upload(&sha, file.contents()).await?; files.push(UploadedFile { - file: file.path.clone(), + file: file.path().to_owned(), sha, size: file.len(), }); @@ -501,17 +501,34 @@ fn env_targets(targets: &[DeploymentTarget]) -> Vec<&'static str> { return vec!["production", "preview", "development"]; } - let mut names: Vec<&'static str> = targets - .iter() - .map(|target| match target { + let mut names: Vec<&'static str> = Vec::with_capacity(targets.len()); + for target in targets { + let name = match target { DeploymentTarget::Production => "production", DeploymentTarget::Preview => "preview", - }) - .collect(); - names.dedup(); + }; + // A plain `Vec::dedup` only catches adjacent repeats; a caller-supplied + // target list is not guaranteed sorted, so this checks the whole list + // built so far instead. + if !names.contains(&name) { + names.push(name); + } + } names } +/// The characters a Vercel API path segment does not need escaped. +/// +/// Everything else — `/`, `?`, `#` and the rest of [`NON_ALPHANUMERIC`] — +/// becomes a percent-escape, so a caller-supplied site name, deployment id, or +/// domain cannot add a path segment, a query string, or a `..` of its own to +/// an authenticated request. +/// +/// [`NON_ALPHANUMERIC`]: percent_encoding::NON_ALPHANUMERIC +fn encode_segment(value: &str) -> impl fmt::Display + '_ { + percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC) +} + /// The SHA-1 digest of a deployment file, hex encoded, as `x-vercel-digest` /// requires. fn digest(contents: &[u8]) -> String { diff --git a/src/providers/vercel/test.rs b/src/providers/vercel/test.rs index 6572daf..c85698b 100644 --- a/src/providers/vercel/test.rs +++ b/src/providers/vercel/test.rs @@ -1131,4 +1131,14 @@ fn an_empty_target_list_means_every_environment() { env_targets(&[DeploymentTarget::Production, DeploymentTarget::Production]), ["production"] ); + // Non-adjacent duplicates: a plain `Vec::dedup` would miss the repeated + // `Production` here because `Preview` sits between the two occurrences. + assert_eq!( + env_targets(&[ + DeploymentTarget::Production, + DeploymentTarget::Preview, + DeploymentTarget::Production, + ]), + ["production", "preview"] + ); } diff --git a/tests/public_api.rs b/tests/public_api.rs index 549e07e..69f08d7 100644 --- a/tests/public_api.rs +++ b/tests/public_api.rs @@ -88,7 +88,7 @@ fn a_bundle_reads_a_directory_and_skips_what_a_build_produces() { let bundle = Bundle::from_dir(root.path()).unwrap(); assert_eq!(bundle.len(), 1); - assert_eq!(bundle.files()[0].path, "package.json"); + assert_eq!(bundle.files()[0].path(), "package.json"); assert!(tinyhosts::EXCLUDED.contains(&"node_modules")); } From 916fe26ac5000818c300544eead47c1303a54fc7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 18 Aug 2026 03:13:38 +0300 Subject: [PATCH 5/6] feat(providers): percent-encode path segments in Vercel provider The Vercel provider now percent-encodes user-supplied identifiers such as site names and deployment IDs before inserting them into URL paths, preventing path traversal attacks where a crafted name like "../other/domains" could redirect a request to an unintended API route. A new `InsecureBaseUrl` error variant and validation function reject plain HTTP base URLs that target non-loopback hosts, ensuring bearer credentials are never sent in cleartext. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.lock | 1 + src/error/mod.rs | 14 ++++++++++++++ src/providers/mod.rs | 32 ++++++++++++++++++++++++++++++-- src/providers/vercel/mod.rs | 34 +++++++++++++++++++++++++--------- src/providers/vercel/test.rs | 23 +++++++++++++++++++++++ 5 files changed, 93 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4262698..f5cb536 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1294,6 +1294,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "hex", + "percent-encoding", "reqwest", "serde", "serde_json", diff --git a/src/error/mod.rs b/src/error/mod.rs index e625f8f..4f49a2f 100644 --- a/src/error/mod.rs +++ b/src/error/mod.rs @@ -136,6 +136,20 @@ pub enum Error { reason: String, }, + /// An alternate API root was given over plain HTTP to a non-loopback host. + /// + /// [`connect_to`](crate::providers::connect_to) sends the account's bearer + /// credential to every request `base_url` produces, so an `http://` root + /// reaching outside the local machine would carry it in cleartext. + /// `https://` is always accepted; `http://` is accepted only against + /// `localhost`, `127.0.0.1`, or `::1`, which is what a test suite or a + /// local mock needs. + #[error("base url {base_url} must be https, or http against a loopback host")] + InsecureBaseUrl { + /// The rejected root, as it was supplied. + base_url: String, + }, + /// A provider was named that this build does not have. /// /// Either the name is not a provider, or its Cargo feature is off. diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 4c11c01..6000753 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -148,13 +148,22 @@ pub fn connect(kind: ProviderKind, credentials: Credentials) -> Result, ) -> Result> { + if let Some(base_url) = base_url { + if !is_secure_base_url(base_url) { + return Err(Error::InsecureBaseUrl { + base_url: base_url.to_owned(), + }); + } + } + match kind { #[cfg(feature = "vercel")] ProviderKind::Vercel => Ok(Box::new(match base_url { @@ -171,6 +180,25 @@ pub fn connect_to( } } +/// Whether `base_url` may carry a bearer credential. +/// +/// Accepts every `https://` root. Accepts `http://` only when its host is +/// `localhost`, `127.0.0.1`/`127.x.x.x`, or `::1` — a mock server's or an +/// egress proxy's loopback listener never leaves the machine, so plain HTTP +/// there costs nothing a caller could intercept. +fn is_secure_base_url(base_url: &str) -> bool { + if let Some(rest) = base_url.strip_prefix("https://") { + return !rest.is_empty(); + } + let Some(rest) = base_url.strip_prefix("http://") else { + return false; + }; + let host = rest.split(['/', '?', '#']).next().unwrap_or(""); + let host = host.rsplit_once(':').map_or(host, |(host, _)| host); + let host = host.trim_start_matches('[').trim_end_matches(']'); + host == "localhost" || host == "127.0.0.1" || host == "::1" || host.starts_with("127.") +} + /// Connects to `kind` with the credential in the process environment. /// /// # Errors diff --git a/src/providers/vercel/mod.rs b/src/providers/vercel/mod.rs index 67ea9ed..29ecd9e 100644 --- a/src/providers/vercel/mod.rs +++ b/src/providers/vercel/mod.rs @@ -22,6 +22,8 @@ //! variables into the project's environment. This crate never sees them, which //! is why [`Host::attach_database`] returns names rather than values. +use std::fmt; + use async_trait::async_trait; use reqwest::Method; use serde_json::Value; @@ -200,9 +202,11 @@ impl Host for Vercel { } async fn find_site(&self, name: &str) -> Result> { - let builder = self - .http - .request(Method::GET, &format!("/v9/projects/{name}"), &[]); + let builder = self.http.request( + Method::GET, + &format!("/v9/projects/{}", encode_segment(name)), + &[], + ); let project: Option = self.http.optional_json(builder, "project").await?; Ok(project.map(Project::into_site)) @@ -243,7 +247,7 @@ impl Host for Vercel { self.http .post_discard( - &format!("/v10/projects/{site}/env"), + &format!("/v10/projects/{}/env", encode_segment(site)), &[("upsert", "true".to_owned())], &body, "environment variables", @@ -255,7 +259,7 @@ impl Host for Vercel { let envs: Envs = self .http .get_json( - &format!("/v10/projects/{site}/env"), + &format!("/v10/projects/{}/env", encode_segment(site)), &[], "environment variables", ) @@ -387,7 +391,11 @@ impl Host for Vercel { async fn deployment(&self, id: &str) -> Result { let deployment: DeploymentBody = self .http - .get_json(&format!("/v13/deployments/{id}"), &[], "deployment") + .get_json( + &format!("/v13/deployments/{}", encode_segment(id)), + &[], + "deployment", + ) .await?; Ok(deployment.into_deployment("")) @@ -414,7 +422,11 @@ impl Host for Vercel { let project = self.project_id(site).await?; let builder = self.http.request( Method::POST, - &format!("/v10/projects/{project}/promote/{deployment}"), + &format!( + "/v10/projects/{}/promote/{}", + encode_segment(&project), + encode_segment(deployment) + ), &[], ); @@ -430,7 +442,7 @@ impl Host for Vercel { let added: DomainBody = self .http .post_json( - &format!("/v10/projects/{site}/domains"), + &format!("/v10/projects/{}/domains", encode_segment(site)), &[], &CreateDomain { name }, "domain", @@ -443,7 +455,11 @@ impl Host for Vercel { async fn list_domains(&self, site: &str) -> Result> { let domains: Domains = self .http - .get_json(&format!("/v9/projects/{site}/domains"), &[], "domain list") + .get_json( + &format!("/v9/projects/{}/domains", encode_segment(site)), + &[], + "domain list", + ) .await?; Ok(domains diff --git a/src/providers/vercel/test.rs b/src/providers/vercel/test.rs index c85698b..3a74545 100644 --- a/src/providers/vercel/test.rs +++ b/src/providers/vercel/test.rs @@ -126,6 +126,29 @@ async fn a_missing_site_is_not_an_error() { assert!(host(&server).find_site("absent").await.unwrap().is_none()); } +#[tokio::test] +async fn a_path_shaped_site_name_cannot_redirect_the_request() { + let server = MockServer::start().await; + // If the raw name reached the URL unescaped, this would request + // `/v9/projects/other/domains` instead — a route this test never mounts. + mount( + &server, + "GET", + "/v9/projects/%2E%2E%2Fother%2Fdomains", + 200, + json!({"id": "prj_4", "name": "../other/domains"}), + ) + .await; + + let site = host(&server) + .find_site("../other/domains") + .await + .unwrap() + .unwrap(); + + assert_eq!(site.id, "prj_4"); +} + #[tokio::test] async fn lists_projects_up_to_a_limit() { let server = MockServer::start().await; From 2491f5acd1f9d54739251728f2a1d67fb09315e1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 18 Aug 2026 03:17:06 +0300 Subject: [PATCH 6/6] feat(providers): restrict plain HTTP to loopback and encode path segments safely The connect_to function now rejects plain HTTP URLs that point to non-loopback hosts, preventing accidental credential exposure over insecure connections. The Vercel provider's path segment encoding is tightened to allow only RFC 3986 unreserved characters, so caller-supplied identifiers cannot inject path separators or query strings into authenticated requests. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/providers/mod.rs | 5 +++-- src/providers/test.rs | 41 ++++++++++++++++++++++++++++++++++++ src/providers/vercel/mod.rs | 22 ++++++++++++------- src/providers/vercel/test.rs | 2 +- 4 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 6000753..ff2575d 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -156,12 +156,13 @@ pub fn connect_to( credentials: Credentials, base_url: Option<&str>, ) -> Result> { - if let Some(base_url) = base_url { - if !is_secure_base_url(base_url) { + match base_url { + Some(base_url) if !is_secure_base_url(base_url) => { return Err(Error::InsecureBaseUrl { base_url: base_url.to_owned(), }); } + Some(_) | None => {} } match kind { diff --git a/src/providers/test.rs b/src/providers/test.rs index 698bc87..c76e0a0 100644 --- a/src/providers/test.rs +++ b/src/providers/test.rs @@ -129,6 +129,47 @@ fn connecting_can_target_another_api_root() { assert_eq!(host.kind(), ProviderKind::Vercel); } +#[test] +fn connecting_over_plain_http_to_a_non_loopback_host_is_rejected() { + let error = connect_to( + ProviderKind::Vercel, + Credentials::new("token").unwrap(), + Some("http://api.example.com"), + ) + .unwrap_err(); + + assert_eq!( + error, + Error::InsecureBaseUrl { + base_url: "http://api.example.com".to_owned() + } + ); +} + +#[test] +fn connecting_over_https_to_any_host_is_allowed() { + let host = connect_to( + ProviderKind::Vercel, + Credentials::new("token").unwrap(), + Some("https://api.example.com"), + ) + .unwrap(); + + assert_eq!(host.kind(), ProviderKind::Vercel); +} + +#[test] +fn connecting_over_plain_http_to_localhost_is_allowed() { + let host = connect_to( + ProviderKind::Vercel, + Credentials::new("token").unwrap(), + Some("http://localhost:1"), + ) + .unwrap(); + + assert_eq!(host.kind(), ProviderKind::Vercel); +} + #[test] fn connecting_from_the_environment_either_works_or_reports_the_missing_key() { match connect_from_env(ProviderKind::Vercel) { diff --git a/src/providers/vercel/mod.rs b/src/providers/vercel/mod.rs index 29ecd9e..2b19bee 100644 --- a/src/providers/vercel/mod.rs +++ b/src/providers/vercel/mod.rs @@ -533,16 +533,22 @@ fn env_targets(targets: &[DeploymentTarget]) -> Vec<&'static str> { names } -/// The characters a Vercel API path segment does not need escaped. +/// The characters a Vercel API path segment does not need escaped: RFC 3986's +/// unreserved set, which is what a site name, deployment id, or domain is +/// ordinarily made of. /// -/// Everything else — `/`, `?`, `#` and the rest of [`NON_ALPHANUMERIC`] — -/// becomes a percent-escape, so a caller-supplied site name, deployment id, or -/// domain cannot add a path segment, a query string, or a `..` of its own to -/// an authenticated request. -/// -/// [`NON_ALPHANUMERIC`]: percent_encoding::NON_ALPHANUMERIC +/// Everything else — `/`, `?`, `#` included — becomes a percent-escape, so a +/// caller-supplied identifier cannot add a path segment, a query string, or a +/// `..` of its own to an authenticated request. +const PATH_SEGMENT: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC + .remove(b'-') + .remove(b'.') + .remove(b'_') + .remove(b'~'); + +/// Percent-encodes `value` for use as one path segment. fn encode_segment(value: &str) -> impl fmt::Display + '_ { - percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC) + percent_encoding::utf8_percent_encode(value, PATH_SEGMENT) } /// The SHA-1 digest of a deployment file, hex encoded, as `x-vercel-digest` diff --git a/src/providers/vercel/test.rs b/src/providers/vercel/test.rs index 3a74545..ef76e55 100644 --- a/src/providers/vercel/test.rs +++ b/src/providers/vercel/test.rs @@ -134,7 +134,7 @@ async fn a_path_shaped_site_name_cannot_redirect_the_request() { mount( &server, "GET", - "/v9/projects/%2E%2E%2Fother%2Fdomains", + "/v9/projects/..%2Fother%2Fdomains", 200, json!({"id": "prj_4", "name": "../other/domains"}), )