use anyhow::Result;
use clap::{Args as ClapArgs, Subcommand};
use crate::client::Client;
use crate::commands::api::{get_and_print, QueryArgs};
#[derive(Subcommand)]
pub enum Cmd {
Users {
#[command(subcommand)]
command: UsersCmd,
},
Installs {
#[command(subcommand)]
command: InstallsCmd,
},
Usage(QueryOnly),
Downloads {
#[command(subcommand)]
command: DownloadsCmd,
},
Prompts {
#[command(subcommand)]
command: PromptsCmd,
},
Geo(QueryOnly),
CliUsage(QueryOnly),
Spec(QueryOnly),
}
#[derive(Subcommand)]
pub enum UsersCmd {
List(QueryOnly),
Show(WithId),
}
#[derive(Subcommand)]
pub enum InstallsCmd {
Metrics(QueryOnly),
List(QueryOnly),
Show(InstallShow),
Events(WithId),
}
#[derive(Subcommand)]
pub enum DownloadsCmd {
Metrics(QueryOnly),
List(QueryOnly),
}
#[derive(Subcommand)]
pub enum PromptsCmd {
Usage(QueryOnly),
Callers(QueryOnly),
Events(QueryOnly),
}
#[derive(ClapArgs)]
pub struct QueryOnly {
#[command(flatten)]
query: QueryArgs,
}
#[derive(ClapArgs)]
pub struct WithId {
#[arg(value_parser = resource_id)]
id: String,
#[command(flatten)]
query: QueryArgs,
}
#[derive(ClapArgs)]
pub struct InstallShow {
#[arg(value_parser = resource_id)]
id: String,
#[arg(long)]
install_id: bool,
#[command(flatten)]
query: QueryArgs,
}
pub async fn run(cmd: Cmd) -> Result<()> {
let client = Client::from_config()?;
let (path, query) = route(cmd);
get_and_print(&client, &path, &query).await.map_err(|e| {
if is_forbidden(&e) {
e.context(
"forbidden — `wk admin` needs a token from an account with the global root role",
)
} else {
e
}
})
}
fn is_forbidden(e: &anyhow::Error) -> bool {
matches!(
e.downcast_ref::<wavekat_platform_client::Error>(),
Some(wavekat_platform_client::Error::Http { status: 403, .. })
)
}
const VOICE: &str = "/api/admin/voice";
fn route(cmd: Cmd) -> (String, QueryArgs) {
match cmd {
Cmd::Users { command } => match command {
UsersCmd::List(a) => ("/api/users".into(), a.query),
UsersCmd::Show(a) => (format!("/api/users/{}", seg(&a.id)), a.query),
},
Cmd::Installs { command } => match command {
InstallsCmd::Metrics(a) => (format!("{VOICE}/installs/metrics"), a.query),
InstallsCmd::List(a) => (format!("{VOICE}/installs"), a.query),
InstallsCmd::Show(a) if a.install_id => (
format!("{VOICE}/installs/by-install-id/{}", seg(&a.id)),
a.query,
),
InstallsCmd::Show(a) => (format!("{VOICE}/installs/{}", seg(&a.id)), a.query),
InstallsCmd::Events(a) => (
format!("{VOICE}/installs/by-install-id/{}/events", seg(&a.id)),
a.query,
),
},
Cmd::Usage(a) => (format!("{VOICE}/usage"), a.query),
Cmd::Downloads { command } => match command {
DownloadsCmd::Metrics(a) => (format!("{VOICE}/downloads/metrics"), a.query),
DownloadsCmd::List(a) => (format!("{VOICE}/downloads"), a.query),
},
Cmd::Prompts { command } => match command {
PromptsCmd::Usage(a) => (format!("{VOICE}/prompt-usage"), a.query),
PromptsCmd::Callers(a) => (format!("{VOICE}/prompt-callers"), a.query),
PromptsCmd::Events(a) => (format!("{VOICE}/prompt-events"), a.query),
},
Cmd::Geo(a) => ("/api/admin/geo".into(), a.query),
Cmd::CliUsage(a) => ("/api/admin/cli-usage".into(), a.query),
Cmd::Spec(a) => ("/api/openapi.json".into(), a.query),
}
}
fn resource_id(raw: &str) -> Result<String, String> {
match raw {
"" => Err("id must not be empty".into()),
"." | ".." => Err(format!("`{raw}` is not a valid id")),
_ => Ok(raw.to_string()),
}
}
fn seg(id: &str) -> String {
let mut out = String::with_capacity(id.len());
for b in id.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Parser)]
struct Harness {
#[command(subcommand)]
cmd: Cmd,
}
fn path_of(argv: &[&str]) -> String {
let h = Harness::try_parse_from(std::iter::once("wk-admin").chain(argv.iter().copied()))
.expect("argv should parse");
route(h.cmd).0
}
#[test]
fn routes_every_command_to_its_endpoint() {
let cases: &[(&[&str], &str)] = &[
(&["users", "list"], "/api/users"),
(&["users", "show", "42"], "/api/users/42"),
(
&["installs", "metrics"],
"/api/admin/voice/installs/metrics",
),
(&["installs", "list"], "/api/admin/voice/installs"),
(
&["installs", "show", "abc"],
"/api/admin/voice/installs/abc",
),
(
&["installs", "show", "abc", "--install-id"],
"/api/admin/voice/installs/by-install-id/abc",
),
(
&["installs", "events", "abc"],
"/api/admin/voice/installs/by-install-id/abc/events",
),
(&["usage"], "/api/admin/voice/usage"),
(
&["downloads", "metrics"],
"/api/admin/voice/downloads/metrics",
),
(&["downloads", "list"], "/api/admin/voice/downloads"),
(&["prompts", "usage"], "/api/admin/voice/prompt-usage"),
(&["prompts", "callers"], "/api/admin/voice/prompt-callers"),
(&["prompts", "events"], "/api/admin/voice/prompt-events"),
(&["geo"], "/api/admin/geo"),
(&["cli-usage"], "/api/admin/cli-usage"),
(&["spec"], "/api/openapi.json"),
];
for (argv, want) in cases {
assert_eq!(path_of(argv), *want, "argv {argv:?}");
}
}
#[test]
fn ids_cannot_escape_their_path_segment() {
assert_eq!(
path_of(&["users", "show", "../admin/settings"]),
"/api/users/..%2Fadmin%2Fsettings"
);
assert_eq!(
path_of(&["users", "show", "1?role=root"]),
"/api/users/1%3Frole%3Droot"
);
}
#[test]
fn dot_segment_ids_are_rejected() {
for bad in ["", ".", ".."] {
assert!(
Harness::try_parse_from(["wk-admin", "users", "show", bad]).is_err(),
"id {bad:?} should be rejected"
);
}
}
#[test]
fn query_flags_pass_through() {
let h = Harness::try_parse_from(["wk-admin", "geo", "-q", "days=30", "--json"]).unwrap();
let (_, q) = route(h.cmd);
assert_eq!(
crate::commands::api::parse_query(&q.query).unwrap(),
vec![("days".to_string(), "30".to_string())]
);
}
}