ref:d2c888bb021404eb4bc3ec979d3e897acd0fb10b

fix(registry): stop double-prefixing /api/v1 on registry token calls (#42)

`Client::url()` already prepends `/api/v1`, but all three `registry token` calls passed paths that started with `/api/v1` themselves. Every request went to `/api/v1/api/v1/registry/tokens`, so the entire command has been non-functional since it shipped in #40: ``` $ anvil registry token list Error: API error (404): server returned an HTML error page (expected JSON) ``` The correct path is confirmed by the server router, which mounts these under `scope "/api/v1/registry"`. Independent of #41 — touches a disjoint set of files, so the two can land in either order. ## Guarding against a recurrence Two guards, because each catches what the other misses: - a `debug_assert!` in `Client::url()`, which fires when a bad path is actually requested; - `tests/api_paths.rs`, which scans the source, so a bad path is caught even when no test exercises that command — which is precisely how this shipped. **I verified the static guard by reintroducing the bug.** Worth mentioning because my first version of it silently passed: it collapsed existing whitespace but did not insert spaces around `(`, so the patterns never matched anything. It now fails on the broken path and passes on the fixed one. ## One caveat on verification I could not confirm the fix end-to-end against the live server. The `/api/v1/registry/tokens` route landed in `3535279`, one commit before `main`'s HEAD, and the deployed server is v0.8.2 — which predates it. So the endpoint still 404s in production until Anvil is redeployed. The path matches the router source; that is as far as I can prove it today.
SHA: d2c888bb021404eb4bc3ec979d3e897acd0fb10b
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-07-22 09:51
Parents: 5a2ca42
3 files changed +98 -3
Type
src/client.rs +9 −0
@@ -90,7 +90,16 @@
}
}
/// Join an API-relative path onto the base URL. Callers pass paths *without*
/// the `/api/v1` prefix — this adds it. Passing an already-prefixed path
/// silently produced `/api/v1/api/v1/...` and a 404, so we assert against it
/// in debug builds and in tests (see `path_must_not_be_api_prefixed`).
fn url(&self, path: &str) -> String {
debug_assert!(
!path.starts_with("/api/v1"),
"path {path:?} is already /api/v1-prefixed; pass an API-relative path \
(e.g. \"/registry/tokens\") — url() adds the prefix"
);
let base = self.base_url.trim_end_matches('/');
format!("{base}/api/v1{path}")
}
src/commands/registry.rs +3 −3
@@ -109,7 +109,7 @@
let client = Client::from_config()?;
let body = serde_json::json!({ "name": args.name, "scopes": scopes });
let resp: serde_json::Value = client.post("/api/v1/registry/tokens", &body).await?;
let resp: serde_json::Value = client.post("/registry/tokens", &body).await?;
let token = resp
.get("token")
@@ -125,7 +125,7 @@
async fn list() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let resp: serde_json::Value = client.get("/api/v1/registry/tokens").await?;
let resp: serde_json::Value = client.get("/registry/tokens").await?;
let tokens = resp
.get("tokens")
@@ -159,7 +159,7 @@
async fn delete(args: DeleteArgs) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
client
.delete_empty(&format!("/registry/tokens/{}", args.id))
.delete_empty(&format!("/api/v1/registry/tokens/{}", args.id))
.await?;
output::success(&format!("Revoked registry token {}", args.id));
Ok(())
tests/api_paths.rs +86 −0
@@ -1,0 +1,86 @@
//! Static guard against double-prefixed API paths.
//!
//! `Client::url()` prepends `/api/v1` to every path it is given, so callers must
//! pass API-relative paths (`"/registry/tokens"`). Passing `"/api/v1/registry/tokens"`
//! produced `…/api/v1/api/v1/registry/tokens` and a 404 that only showed up at
//! runtime — the whole `anvil registry token` surface shipped broken this way.
//!
//! `Client::url()` also carries a `debug_assert!`, but that only fires when the
//! offending call is actually executed. This test scans the source instead, so a
//! bad path is caught even if nothing covers that command.
use std::path::Path;
/// Every `Client` method that routes through `Client::url()`.
const URL_METHODS: &[&str] = &[
"get",
"get_with_query",
"post",
"put",
"patch",
"delete_empty",
"post_empty",
"get_raw",
"get_sse_stream",
"upload_file",
"download_file",
];
/// Strip *all* whitespace so a call site matches the same way whether it was
/// written on one line or wrapped across several by rustfmt.
fn flatten(src: &str) -> String {
src.chars().filter(|c| !c.is_whitespace()).collect()
}
fn rust_sources(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).expect("read_dir") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
rust_sources(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
#[test]
fn client_call_sites_are_not_api_prefixed() {
let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::new();
rust_sources(&src_dir, &mut files);
assert!(!files.is_empty(), "found no sources under {src_dir:?}");
let mut offenders = Vec::new();
for file in &files {
let flat = flatten(&std::fs::read_to_string(file).expect("read source"));
for method in URL_METHODS {
// Matches `.get("/api/v1…`, `.delete_empty(&format!("/api/v1…`,
// and the same with arbitrary whitespace between tokens.
// Only literal paths are checkable statically; paths built from
// variables are covered by the debug_assert! in Client::url().
for prefix in [
format!(".{method}(\"/api/v1"),
format!(".{method}(&format!(\"/api/v1"),
format!(".{method}(format!(\"/api/v1"),
] {
if flat.contains(&prefix) {
offenders.push(format!(
"{}: .{method}() called with an /api/v1-prefixed path",
file.strip_prefix(env!("CARGO_MANIFEST_DIR"))
.unwrap_or(file)
.display()
));
}
}
}
}
assert!(
offenders.is_empty(),
"Client::url() already prepends /api/v1 — these call sites double it:\n {}\n\n\
Pass an API-relative path instead, e.g. \"/registry/tokens\".",
offenders.join("\n ")
);
}