//! `atgc api`: one raw XRPC call, to whichever service takes it. //! //! Tangled's lexicon is much larger than the part atgc has commands for. //! Issues, labels, stars, follows, collaborators, secrets, artifacts, //! pipelines and notifications all exist as `sh.tangled.*` methods and //! collections today, and none of them has a verb here. This is the escape //! hatch: name a method, give it parameters, get its answer. It is also the //! tool for the question a command cannot answer: *why does this listing //! disagree with the record it claims to describe*, because it can ask both //! sides in the spelling they actually speak. //! //! # Which credential, and why the host decides it //! //! "An XRPC call" is three different acts in this stack, and they are //! authorized in three unrelated ways: //! //! 1. **A call to the account's own PDS**: `com.atproto.repo.getRecord`, //! `listRecords`, `createRecord`, `applyWrites`: carries the OAuth //! session's access token, which is DPoP-bound: a proof signed with the //! session's key, over this exact method and URL, rides beside it. //! 2. **A knot procedure**: `sh.tangled.repo.merge`, `forkSync`: carries a //! *service-auth* JWT the account's PDS mints, naming the knot as audience //! and the one method as `lxm`. Nothing about the OAuth token is presented //! to the knot at all; the `rpc:?aud=*` scope is spent at the PDS, //! minting the JWT. See [`crate::clients::tangled::knot`]. //! 3. **A knot query, and anything at Bobbin or the appview**: public reads, //! carrying nothing. //! //! A flag naming the *credential* would therefore be a flag asking the user to //! already know the thing they came here to find out, and getting it wrong is //! not a harmless mistake: sending an OAuth access token to a knot hands a //! third-party host a credential for the PDS. So the axis is `--host`, which //! is a fact about where the method lives and is written in the lexicon, //! and atgc derives the credential from it. There is one flag, it has four //! values, and each one is a service rather than a secret. //! //! The consequence worth stating: a knot query and a knot procedure carry //! different credentials under the same `--host`, decided by GET versus POST. //! That is not a special case, it is the same rule: a knot's queries are //! public and its procedures are not. //! //! # Why there is no `--json` //! //! Every other command has one because it has two renderings and `--json` //! picks the machine's. This one has a single rendering: the service's own //! answer, which is JSON, so the flag would be a switch with nothing behind //! it, and a no-op flag is worse than an absent one: it reads as a promise //! that the output changes shape when asked, and scripts get written against //! that promise. Colour, hyperlinks and padding are off unconditionally, which //! is what `--json` really buys everywhere else. //! //! # `--dry-run` rather than a confirmation //! //! `com.atproto.repo.deleteRecord` and `sh.tangled.repo.delete` are both //! reachable through here, and neither is reversible. A prompt would be the //! wrong guard: atgc goes non-interactive off a terminal, which is exactly //! where a script driving this command runs, so the prompt that would protect //! a person at a keyboard is the one that would be answered by whatever is on //! stdin, and every other mutating command in atgc takes `--dry-run` instead. //! So does this one, and it prints the whole request: method, URL, headers and //! body, with the credential reduced to eight hex of its SHA-256 the way //! [`crate::logging::oauth`] reduces every token it records. A dry run that //! leaked the access token would be a worse hazard than the delete it declined //! to send. The scope checks run on a dry run too, as warnings, so a call that //! could not have gone through does not read back clean. use anyhow::{Context, Result}; /// The service a call is addressed to, which is also what decides the /// credential: see the module doc. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum Host { /// The acting account's own PDS, as its session names it. Pds, /// One knot, by hostname. Named rather than derived: the knot a repo /// lives on is a field of its `sh.tangled.repo` record, and finding that /// record from a checkout needs the appview's index to turn a repo DID /// back into an owner and a name. Guessing it off the `origin` remote is /// wrong for every repo cloned through tangled.org, which proxies git for /// its knots, so a wrong guess would send a service-auth token, minted /// for `did:web:tangled.org`, to a host that is not the knot. `atgc repo /// view /` prints the knot. Knot(String), /// Tangled's website. Public. Appview, /// Bobbin, Tangled's appview API. Public. Bobbin, } impl Host { /// How the host is named back in a dry run's report and in errors: the /// same spelling `--host` takes. fn label(&self) -> String { match self { Host::Pds => "pds".to_string(), Host::Knot(host) => format!("knot:{host}"), Host::Appview => "appview".to_string(), Host::Bobbin => "bobbin".to_string(), } } } /// `--host`'s four spellings. /// /// A refusal here has to teach, because this is the flag somebody reaching /// for this command gets wrong first: it lists the values and says where the /// knot hostname is written down. fn parse_host(input: &str) -> std::result::Result { let input = input.trim(); match input { "pds" => Ok(Host::Pds), "appview" => Ok(Host::Appview), "bobbin" => Ok(Host::Bobbin), "knot" => Err( "--host knot needs the knot's hostname: --host knot:knot1.tangled.sh\n\ `atgc repo view /` prints the knot a repo lives on" .to_string(), ), other => match other.strip_prefix("knot:") { Some("") => Err("--host knot: needs a hostname after the colon".to_string()), Some(host) => Ok(Host::Knot(host.to_string())), None => Err(format!( "unknown host {other:?}; expected pds, knot:, appview or bobbin" )), }, } } /// GET or POST, which for XRPC is the query/procedure distinction. #[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)] pub(crate) enum Verb { /// A query: parameters in the query string, no body. Get, /// A procedure: a JSON body. Post, } #[derive(clap::Args, Debug)] pub(crate) struct ApiArgs { /// The XRPC method to call, as an NSID: com.atproto.repo.listRecords, /// sh.tangled.repo.merge #[arg(value_name = "NSID")] pub nsid: String, /// Which service to send it to, and so which credential it carries: /// pds, appview, bobbin, or `knot:` and a knot's hostname #[arg(long, value_name = "HOST", default_value = "pds", value_parser = parse_host)] pub host: Host, /// Add a typed parameter, key=value. true, false, null and numbers are /// sent as those; anything else is a string. `gh api`'s -F #[arg(short = 'F', long = "field", value_name = "KEY=VALUE")] pub field: Vec, /// Add a string parameter, key=value, with no type guessing at all. /// `gh api`'s -f #[arg(short = 'f', long = "raw-field", value_name = "KEY=VALUE")] pub raw_field: Vec, /// Read the whole request body from a JSON file, or from stdin with -. /// Implies a procedure #[arg(long, value_name = "FILE", conflicts_with_all = ["field", "raw_field"])] pub input: Option, /// Send it as a query (get) or a procedure (post). Without this, --input /// means post and everything else means get #[arg( short = 'X', long = "method", value_name = "GET|POST", ignore_case = true )] pub method: Option, /// Print the request that would be sent: method, URL, headers with the /// credential redacted, body, and send nothing #[arg(long)] pub dry_run: bool, } /// The request `--dry-run` prints, and the only output of this command that /// atgc composes rather than relays. /// /// `dry_run` is always `true` here, and carried anyway: it is the field every /// other write command's `--json` carries, and a caller that greps for it /// should find it in the one place where it can only mean one thing. #[derive(serde::Serialize, Debug)] struct DryRun { dry_run: bool, /// `--host`'s own spelling, so the report says what to type. host: String, /// `GET` or `POST`. http_method: String, url: String, /// Every header atgc would set, in the order a reader wants them. /// `authorization` is present as a description of the credential and /// never as the credential: see the module doc. headers: std::collections::BTreeMap, /// The JSON body, or `null` for a query, which has none. body: Option, } pub(crate) async fn api(args: ApiArgs) -> Result<()> { // Not `--json`: this command has no other rendering. What the call does // is switch every decoration off on stdout, which is the half of `--json` // that means anything here. crate::term::jsonout::init(true); let nsid = nsid(&args.nsid)?; let fields = fields(&args)?; let input = read_input(args.input.as_deref())?; let post = is_procedure(&args, input.is_some())?; let body = request_body(post, input, &fields); let params = query_params(post, &fields); match &args.host { Host::Pds => pds(&args, &nsid, post, body, ¶ms).await, Host::Knot(host) if post => { knot(&args, host, &nsid, body.unwrap_or(serde_json::json!({}))).await } Host::Knot(host) => { public( &args, crate::clients::endpoints::knot(host), &nsid, false, None, ¶ms, ) .await } Host::Appview => { public( &args, crate::clients::endpoints::appview(), &nsid, post, body, ¶ms, ) .await } Host::Bobbin => { public( &args, crate::clients::endpoints::bobbin(), &nsid, post, body, ¶ms, ) .await } } } /// A call to the acting account's PDS, as that account. /// /// The session is resumed before anything else, and not only for the token: /// [`crate::cmd::auth::agent_for_did`] is what refreshes an expired one and writes /// it back, and the PDS's own base URL is a field of the session, so no DID /// document has to be fetched to find out where to send this. async fn pds( args: &ApiArgs, nsid: &str, post: bool, body: Option, params: &[(String, String)], ) -> Result<()> { let selection = crate::config::account::select().await?; selection.announce(); // Before the request, so a session that could not have made it says which // scope is missing rather than relaying a 403 that names nothing. check_pds_scopes(&selection, nsid, body.as_ref(), args.dry_run)?; // The session that was just resumed, handed back rather than resolved // again. `api` signs whatever the caller typed with the credential it is // given, so a second lookup naming a different session would sign with one // that was never refreshed — or, where an agent and the account's owner // are both logged in, with the other plane's. let (_agent, stored) = crate::cmd::auth::resume(&selection.did).await?; let mut session = crate::clients::atproto::oauth::sessions::session_data(&selection.did, &stored.session_id) .await?; let base = session.host_url.to_string(); let url = crate::clients::xrpc::endpoint(&base, nsid, &borrowed(params)); let credential = crate::clients::xrpc::Credential::Session(&mut session); if args.dry_run { return dry_run(args, &url, post, &body, credential.described()); } let answer = crate::clients::xrpc::send(&url, post, body.as_ref(), credential).await?; answered(&args.host, nsid, post, answer) } /// A knot procedure, authorized by a service-auth JWT for this one method. async fn knot(args: &ApiArgs, host: &str, nsid: &str, body: serde_json::Value) -> Result<()> { let selection = crate::config::account::select().await?; selection.announce(); let scope = crate::clients::tangled::scope::require_rpc( crate::cmd::acting_scope(&selection.did).as_deref(), selection.handle.as_deref(), nsid, ); let url = crate::clients::xrpc::endpoint(&crate::clients::endpoints::knot(host), nsid, &[]); if args.dry_run { if let Err(e) = scope { crate::term::say::warning!(Auth, "would fail: {e}"); } // Deliberately not minted: a dry run must not spend a scope or leave // a getServiceAuth in the OAuth log for a call that was never made. return dry_run( args, &url, true, &Some(body), Some(format!( "Bearer " )), ); } scope?; let agent = crate::cmd::auth::agent_for_did(&selection.did).await?; let token = crate::clients::tangled::knot::service_auth(&agent, host, nsid).await?; let answer = crate::clients::xrpc::send( &url, true, Some(&body), crate::clients::xrpc::Credential::ServiceAuth(&token), ) .await?; answered(&args.host, nsid, true, answer) } /// A public read: a knot query, or anything at Bobbin or the appview. No /// session is selected and none is needed, exactly as `atgc search` needs /// none. async fn public( args: &ApiArgs, base: String, nsid: &str, post: bool, body: Option, params: &[(String, String)], ) -> Result<()> { let url = crate::clients::xrpc::endpoint(&base, nsid, &borrowed(params)); if args.dry_run { return dry_run(args, &url, post, &body, None); } let answer = crate::clients::xrpc::send( &url, post, body.as_ref(), crate::clients::xrpc::Credential::None, ) .await?; answered(&args.host, nsid, post, answer) } /// `Vec<(String, String)>` as the `&[(&str, &str)]` the URL builder takes. fn borrowed(params: &[(String, String)]) -> Vec<(&str, &str)> { params .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect() } /// Print the request and send nothing. fn dry_run( args: &ApiArgs, url: &str, post: bool, body: &Option, authorization: Option, ) -> Result<()> { let mut headers = std::collections::BTreeMap::new(); headers.insert("accept".to_string(), "application/json".to_string()); if let Some(authorization) = authorization { headers.insert("authorization".to_string(), authorization); } if post { headers.insert("content-type".to_string(), "application/json".to_string()); } crate::term::say::note!(Cli, "dry run; nothing was sent"); crate::term::jsonout::emit(&DryRun { dry_run: true, host: args.host.label(), http_method: if post { "POST" } else { "GET" }.to_string(), url: url.to_string(), headers, body: body.clone(), }) } /// What came back: the body on stdout when the service said yes, and a /// classified failure when it said no. fn answered( host: &Host, nsid: &str, post: bool, answer: crate::clients::xrpc::Answer, ) -> Result<()> { if !answer.status.is_success() { return Err(refusal(host, nsid, post, &answer)); } print(&answer) } /// The answer, on stdout, in the shape the service sent it. /// /// JSON is re-emitted through [`crate::term::jsonout`] rather than passed /// through byte for byte, so it is pretty-printed like every other `--json` /// document atgc writes and a caller reading a captured file gets the same /// thing. Anything else: `com.atproto.sync.getBlob` is the case that really /// happens: goes out unaltered, because the alternative is a command that /// corrupts the one kind of answer it cannot understand. fn print(answer: &crate::clients::xrpc::Answer) -> Result<()> { if answer.body.is_empty() { // `null` and not nothing: stdout is the answer, and a caller piping // this into `jq` should get a value rather than a parse error. crate::term::say::note!(Cli, "{} with an empty body; printing null", answer.status); println!("null"); return Ok(()); } match answer.json() { Some(value) => crate::term::jsonout::emit(&value), None => { crate::term::say::note!( Cli, "the answer is not JSON ({} bytes); printing it as it arrived", answer.body.len() ); use std::io::Write; std::io::stdout() .write_all(&answer.body) .context("could not write the answer to stdout") } } } /// A service that answered and said no, as an error carrying the right exit /// status. /// /// The `error` and `message` fields are the point: a status alone cannot tell /// "no such collection" from "no such repo", and every service in this stack /// puts the difference in those two fields. fn refusal( host: &Host, nsid: &str, post: bool, answer: &crate::clients::xrpc::Answer, ) -> anyhow::Error { let error = answer.error(); let said = match (&error, answer.message()) { (Some(error), Some(message)) => format!("{error}: {message}"), (Some(error), None) => error.clone(), (None, Some(message)) => message, (None, None) => String::from_utf8_lossy(&answer.body).trim().to_string(), }; let mut text = format!( "{} refused {nsid} ({}){}", host.label(), answer.status, match said.is_empty() { true => String::new(), false => format!(": {said}"), } ); if let Some(hint) = wrong_verb_hint(answer.status, post) { text.push('\n'); text.push_str(&hint); } crate::exit::fail( crate::exit::from_error_name(error.as_deref(), answer.status), text, ) } /// The line to add when the refusal looks like the query/procedure guess /// having gone the wrong way. /// /// XRPC declares which a method is and atgc cannot know it for a method named /// on a command line, so the guess is `--input` means a procedure and nothing /// else does. When it is wrong the service answers 404, 405 or 501: none of /// which says "you used the wrong HTTP verb", and a reader with no XRPC in /// their head reads that as "no such method". fn wrong_verb_hint(status: reqwest::StatusCode, post: bool) -> Option { let looks_like_it = matches!(status.as_u16(), 404 | 405 | 501); if !looks_like_it { return None; } Some(match post { true => { "if this method is a query rather than a procedure, send it with -X get".to_string() } false => "if this method is a procedure rather than a query, send it with -X post \ (or --input, which implies one)" .to_string(), }) } /// Refuse a method that is not an NSID before building a URL out of it. /// /// jacquard's own parser, so `atgc api` agrees with what it would send in a /// service-auth `lxm` claim rather than having a second opinion about what a /// method name is. fn nsid(input: &str) -> Result { let input = input.trim(); jacquard::types::string::Nsid::::new_owned(input).map_err( |e| { crate::exit::fail( crate::exit::Exit::Usage, format!( "{input:?} is not an XRPC method name: {e}\n\ a method is an NSID: a reversed domain and a name, like \ com.atproto.repo.listRecords" ), ) }, )?; Ok(input.to_string()) } /// `-F` and `-f`, in the order they were given, each already typed. /// /// The two flags are `gh api`'s, spelling and meaning both, because that is /// the muscle memory somebody arrives with: `-F` guesses a type, `-f` never /// does. What is deliberately not carried over is `gh`'s `key=@file`: a body /// from a file is what `--input` is, and two spellings of it would be a /// choice nobody needs to make. fn fields(args: &ApiArgs) -> Result> { let mut out = Vec::new(); for (raw, typed) in args .field .iter() .map(|f| (f, true)) .chain(args.raw_field.iter().map(|f| (f, false))) { out.push(field(raw, typed)?); } Ok(out) } /// One `key=value` pair. fn field(raw: &str, typed: bool) -> Result<(String, serde_json::Value)> { let flag = if typed { "-F" } else { "-f" }; let (key, value) = raw.split_once('=').ok_or_else(|| { crate::exit::fail( crate::exit::Exit::Usage, format!("{flag} {raw:?} is not key=value"), ) })?; if key.is_empty() { return Err(crate::exit::fail( crate::exit::Exit::Usage, format!("{flag} {raw:?} has no parameter name before the ="), )); } let value = match typed { // Only the four JSON scalars a shell can spell. An object or an array // typed at a prompt is what `--input` is for, and reading one here // would mean `-F body={"x":1}` and `-F body=hello` differing by a // character. true => match value { "true" => serde_json::Value::Bool(true), "false" => serde_json::Value::Bool(false), "null" => serde_json::Value::Null, _ => match value.parse::() { Ok(number) => serde_json::Value::Number(number), Err(_) => serde_json::Value::String(value.to_string()), }, }, false => serde_json::Value::String(value.to_string()), }; Ok((key.to_string(), value)) } /// `--input`: a JSON document from a file, or from stdin for `-`. fn read_input(spec: Option<&str>) -> Result> { let Some(spec) = spec else { return Ok(None); }; let text = match spec { "-" => { use std::io::Read; let mut text = String::new(); std::io::stdin() .read_to_string(&mut text) .context("could not read the request body from stdin")?; text } path => std::fs::read_to_string(path) .with_context(|| format!("could not read the request body from {path}"))?, }; let value = serde_json::from_str(&text).map_err(|e| { crate::exit::fail( crate::exit::Exit::Usage, format!("the request body is not JSON: {e}"), ) })?; Ok(Some(value)) } /// Whether this is a procedure. `-X` decides when it was given; otherwise /// `--input` does, because a body is the one unambiguous evidence of a /// procedure a command line can carry. /// /// A query is the default rather than a guess from the method name, and that /// is the safe direction: a GET that should have been a POST is a 404, and a /// POST that should have been a GET can be a write. fn is_procedure(args: &ApiArgs, has_input: bool) -> Result { match args.method { Some(Verb::Get) if has_input => Err(crate::exit::fail( crate::exit::Exit::Usage, "-X get sends a query, which has no body, and --input is one\n\ drop one of them: -X post to send the body, or --input to send neither", )), Some(Verb::Get) => Ok(false), Some(Verb::Post) => Ok(true), None => Ok(has_input), } } /// The request body: `--input` whole, or the fields assembled into an object, /// or nothing at all for a query. fn request_body( post: bool, input: Option, fields: &[(String, serde_json::Value)], ) -> Option { if !post { return None; } if let Some(input) = input { return Some(input); } Some(serde_json::Value::Object( fields.iter().cloned().collect::>(), )) } /// The query string: the fields, for a query, stringified. /// /// A parameter is text on the wire whichever flag produced it, so `-F limit=5` /// and `-f limit=5` build the same URL. The typing `-F` does is about a /// procedure's JSON body, and saying so beats a flag that silently means /// nothing half the time. fn query_params(post: bool, fields: &[(String, serde_json::Value)]) -> Vec<(String, String)> { if post { return Vec::new(); } fields .iter() .map(|(key, value)| { let text = match value { serde_json::Value::String(s) => s.clone(), other => other.to_string(), }; (key.clone(), text) }) .collect() } /// Refuse a PDS write this session's grant does not cover, before making it. /// /// The check `scope.rs` already knows how to make, reached through the one /// thing a raw call carries that names a collection: the `collection` field of /// a `createRecord`, `putRecord` or `deleteRecord` body, and each op's own for /// an `applyWrites` batch. **No scope is added for this command**: a scope /// added to the list is a re-login for every account, and an escape hatch is /// the last thing that should cost one. What it can reach is exactly what the /// grant already covers, and what it cannot, it says so about. /// /// Silent for every other method. A read needs no scope, and a write method /// this build has never heard of has no collection to check: reporting *that* /// as a scope problem would be a guess dressed as a refusal. fn check_pds_scopes( selection: &crate::config::account::Selection, nsid: &str, body: Option<&serde_json::Value>, dry_run: bool, ) -> Result<()> { let Some(body) = body else { return Ok(()) }; if !matches!( nsid, "com.atproto.repo.createRecord" | "com.atproto.repo.putRecord" | "com.atproto.repo.deleteRecord" | "com.atproto.repo.applyWrites" ) { return Ok(()); } for collection in collections(body) { let held = crate::clients::tangled::scope::require_scope( crate::cmd::acting_scope(&selection.did).as_deref(), selection.handle.as_deref(), &collection, ); match (held, dry_run) { (Ok(()), _) => {} (Err(e), true) => crate::term::say::warning!(Auth, "would fail: {e}"), (Err(e), false) => return Err(e), } } Ok(()) } /// Every collection a write body names: its own, and one per op of a batch. fn collections(body: &serde_json::Value) -> Vec { let mut out: Vec = body["collection"] .as_str() .map(str::to_string) .into_iter() .collect(); if let Some(writes) = body["writes"].as_array() { out.extend( writes .iter() .filter_map(|write| write["collection"].as_str()) .map(str::to_string), ); } out.sort_unstable(); out.dedup(); out } #[cfg(test)] mod tests { use super::{ Host, collections, field, parse_host, query_params, request_body, wrong_verb_hint, }; use crate::exit::Exit; use reqwest::StatusCode; use serde_json::json; /// The flag somebody gets wrong first. Every refusal has to name the /// values, and the bare `knot` case, the spelling anyone would try, /// has to say where the hostname is written down rather than only that /// one is missing. #[test] fn the_host_flag_teaches_when_it_refuses() { assert_eq!(parse_host("pds").unwrap(), Host::Pds); assert_eq!(parse_host(" bobbin ").unwrap(), Host::Bobbin); assert_eq!(parse_host("appview").unwrap(), Host::Appview); assert_eq!( parse_host("knot:knot1.tangled.sh").unwrap(), Host::Knot("knot1.tangled.sh".to_string()) ); let bare = parse_host("knot").unwrap_err(); assert!(bare.contains("knot:knot1.tangled.sh"), "{bare}"); assert!(bare.contains("atgc repo view"), "{bare}"); assert!(parse_host("knot:").unwrap_err().contains("hostname")); let unknown = parse_host("pdss").unwrap_err(); assert!(unknown.contains("pds, knot:, appview or bobbin")); // The label is the spelling the flag takes, so a dry run's report can // be pasted back onto a command line. assert_eq!(Host::Pds.label(), "pds"); assert_eq!( Host::Knot("knot1.tangled.sh".to_string()).label(), "knot:knot1.tangled.sh" ); } /// `-F` guesses a type and `-f` never does, which is `gh api`'s split and /// the reason both flags exist. The pair that matters is `-F n=1` against /// `-f n=1`: one is a JSON number and one is a JSON string, and a lexicon /// that declares an integer refuses the second. #[test] fn a_typed_field_and_a_string_field_differ_where_it_counts() { assert_eq!(field("limit=5", true).unwrap().1, json!(5)); assert_eq!(field("limit=5", false).unwrap().1, json!("5")); assert_eq!(field("ok=true", true).unwrap().1, json!(true)); assert_eq!(field("ok=true", false).unwrap().1, json!("true")); assert_eq!(field("x=null", true).unwrap().1, json!(null)); assert_eq!(field("x=-1.5", true).unwrap().1, json!(-1.5)); // Anything that is not one of the four scalars is a string, including // the shapes that look like JSON and are not scalars. assert_eq!(field("q=hello", true).unwrap().1, json!("hello")); assert_eq!(field("q=[1]", true).unwrap().1, json!("[1]")); assert_eq!(field("q={\"a\":1}", true).unwrap().1, json!("{\"a\":1}")); // A value carrying its own `=` keeps it: only the first splits. assert_eq!(field("q=a=b", false).unwrap().1, json!("a=b")); // And an empty value is a value. assert_eq!(field("q=", false).unwrap().1, json!("")); for bad in ["nope", "=value"] { let err = field(bad, true).unwrap_err(); assert_eq!(crate::exit::classify(&err), Exit::Usage, "{bad}"); } } /// Where the fields go, which is the whole difference between a query and /// a procedure: the query string in one, a JSON object in the other. A /// procedure sends no query parameters and a query sends no body, so /// neither can silently carry the other's arguments into the void. #[test] fn fields_become_a_query_string_or_a_body_and_never_both() { let fields = vec![ ("repo".to_string(), json!("did:plc:abc")), ("limit".to_string(), json!(5)), ]; let query = query_params(false, &fields); assert_eq!( query, vec![ ("repo".to_string(), "did:plc:abc".to_string()), // Stringified without its JSON quotes: a parameter is text on // the wire, and `limit="5"` is not a limit. ("limit".to_string(), "5".to_string()), ] ); assert_eq!(request_body(false, None, &fields), None); assert!(query_params(true, &fields).is_empty()); assert_eq!( request_body(true, None, &fields), Some(json!({ "repo": "did:plc:abc", "limit": 5 })) ); // `--input` is the whole body and the fields cannot reach it — clap // refuses the combination, and this is the half that would matter if // that declaration were ever dropped. assert_eq!( request_body(true, Some(json!({ "only": "this" })), &fields), Some(json!({ "only": "this" })) ); // A procedure with nothing to say still sends an object: a PDS // refuses a POST with no body at all. assert_eq!(request_body(true, None, &[]), Some(json!({}))); } /// The one mistake this command's shape makes likely: XRPC declares /// query-or-procedure and a command line cannot. The hint has to point /// the *other* way from whatever was sent, or it is advice to repeat the /// failure. #[test] fn a_wrong_verb_is_named_rather_than_left_as_a_404() { assert!( wrong_verb_hint(StatusCode::NOT_FOUND, false) .unwrap() .contains("-X post") ); assert!( wrong_verb_hint(StatusCode::METHOD_NOT_ALLOWED, true) .unwrap() .contains("-X get") ); assert!( wrong_verb_hint(StatusCode::NOT_IMPLEMENTED, false) .unwrap() .contains("-X post") ); // A refusal that is plainly about something else must not suggest it: // a 403 with "try the other verb" beside it is noise on top of a // permissions problem. assert_eq!(wrong_verb_hint(StatusCode::FORBIDDEN, false), None); assert_eq!(wrong_verb_hint(StatusCode::BAD_REQUEST, true), None); } /// Which collections a write body claims, since that is the only thing a /// raw call carries that a scope check can be made from. A batch names /// one per op, and a stack reconcile really does mix them. #[test] fn a_write_body_names_the_collections_its_scope_check_needs() { assert_eq!( collections(&json!({ "collection": "sh.tangled.repo.pull" })), vec!["sh.tangled.repo.pull"] ); assert_eq!( collections(&json!({ "repo": "did:plc:abc", "writes": [ { "collection": "sh.tangled.repo.pull", "rkey": "a" }, { "collection": "sh.tangled.repo.pull", "rkey": "b" }, { "collection": "sh.tangled.feed.comment", "rkey": "c" }, ] })), vec!["sh.tangled.feed.comment", "sh.tangled.repo.pull"] ); // A body that names none is not a scope failure; it is a body this // build has no opinion about. assert!(collections(&json!({ "repo": "did:plc:abc" })).is_empty()); assert!(collections(&json!({ "writes": "not an array" })).is_empty()); } }