Something went wrong. Try again.
atproto git client
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886//! `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:<nsid>?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 <owner>/<name>` 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<Host, String> { 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 <owner>/<name>` 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:<hostname>, 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<String>, /// 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<String>, /// 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<String>, /// 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<Verb>, /// 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<String, String>, /// The JSON body, or `null` for a query, which has none. body: Option<serde_json::Value>,}
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<serde_json::Value>, 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 <service-auth JWT, minted when sent: aud=did:web:{host} lxm={nsid}>" )), ); } 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<serde_json::Value>, 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<serde_json::Value>, authorization: Option<String>,) -> 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<String> { 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<String> { let input = input.trim(); jacquard::types::string::Nsid::<jacquard::common::DefaultStr>::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<Vec<(String, serde_json::Value)>> { 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::<serde_json::Number>() { 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<Option<serde_json::Value>> { 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<bool> { 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<serde_json::Value>, fields: &[(String, serde_json::Value)],) -> Option<serde_json::Value> { if !post { return None; } if let Some(input) = input { return Some(input); } Some(serde_json::Value::Object( fields.iter().cloned().collect::<serde_json::Map<_, _>>(), ))}
/// 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<String> { let mut out: Vec<String> = 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:<hostname>, 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()); }}