Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,19 +245,20 @@ Use `--profile` to change the section name.
`source-coop api` sends any request to `/api/v1`, signed in as you, and prints the response:

```bash
source-coop api products/my-org -X GET -F limit=5
source-coop api products/my-org -q limit=5
source-coop api products/my-org -f product_id=new -f title="New" -f description="" \
-f visibility=public -f data_connection_id=my-connection
source-coop api products/my-org/new -X PATCH --input changes.json
source-coop api products/my-org/new -X PATCH -F changes.json -f title="Renamed"
echo '{"title": "Renamed", "tags": ["a"]}' | source-coop api products/my-org/new -X PATCH -F -
```

`-f key=value` sends a string, and `-F key=value` sends `true`, `false`, `null` and numbers as JSON. Fields go in the query string for `GET` and in a JSON body otherwise, and the method is `POST` when fields or `--input` are given; a `GET` takes no `--input`. Paths stay under `/api/v1`. A status other than success exits non-zero, with the exit code for its kind (see "JSON output and exit codes").
`-q key=value` adds a query parameter. `-f key=value` sets a field in the JSON body: a value that parses as JSON (`5`, `true`, `null`, `["a","b"]`) is sent as that, and anything else as a string. To send something that looks like JSON as a string, quote it: `-f product_id='"2024"'`. `-F file.json` (`-` for stdin) reads the body from a file, and `-f` fields override its keys. The method is `GET`, or `POST` when there's a body; `-X` picks another, and a `GET` takes no body. Paths stay under `/api/v1`, and a query string in the path is kept. The paths and their fields are listed in the API's OpenAPI spec, at [`https://source.coop/api/openapi`](https://source.coop/api/openapi). The response body is printed to stdout as the API sent it, so `api` takes no `--json`. A status other than success exits non-zero, with the exit code for its kind (see "JSON output and exit codes").

Reading public data needs no credentials. Anything else acts as whoever ran `source-coop auth login`, which asks Ory for an access token meant for the API (`--audience`, default `https://source.coop`) and refreshes it as needed. `SOURCE_TOKEN`, if set, is sent instead. `--api-url` (or `SOURCE_API_URL`) points the CLI at another deployment, such as a local `http://localhost:3000`.

### JSON output and exit codes

Every command that reports information takes `--json`: `auth status`, and `product list`, `view`, `create`, `edit` and `delete` (which print the resulting record). Under `--json`, stdout holds one JSON document and nothing else, with the API's field names and RFC 3339 times; notes and progress go to stderr. Fields may be added over time, but not renamed or removed.
Every command that reports information takes `--json`: `auth status`, and `product list`, `view`, `create`, `edit` and `delete` (which print the resulting record). `api` prints the API's own JSON response and takes no `--json`. Under `--json`, stdout holds one JSON document and nothing else, with the API's field names and RFC 3339 times; notes and progress go to stderr. Fields may be added over time, but not renamed or removed.

A failure under `--json` is JSON too, on stderr, in the API's error shape. `field_errors` names what to fix when the API rejected the input:

Expand Down
151 changes: 83 additions & 68 deletions src/api_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,61 +13,49 @@ pub struct ApiArgs {
/// The path under /api/v1, e.g. `products/my-org` (a query string is kept)
path: String,

/// HTTP method; GET, or POST when fields or --input are given
/// HTTP method; GET, or POST when there's a body (-f or -F)
#[arg(long, short = 'X')]
method: Option<String>,

/// A field, `key=value`, sent as a string; in the query string for GET
#[arg(long = "raw-field", short = 'f', value_name = "KEY=VALUE")]
raw_fields: Vec<String>,
/// A query parameter, `key=value`; doesn't change the method
#[arg(long, short = 'q', value_name = "KEY=VALUE")]
query: Vec<String>,

/// A field, `key=value`, where `true`, `false`, `null` and numbers are
/// sent as JSON rather than strings
#[arg(long = "field", short = 'F', value_name = "KEY=VALUE")]
/// A body field, `key=value`; a value that parses as JSON (`5`, `true`,
/// `["a"]`) is sent as that, anything else as a string (quote it, `'"5"'`,
/// to send a number as a string)
#[arg(long = "field", short = 'f', value_name = "KEY=VALUE")]
fields: Vec<String>,

/// Send this file's JSON as the request body (`-` for stdin)
#[arg(long, value_name = "PATH", conflicts_with_all = ["raw_fields", "fields"])]
input: Option<String>,
/// Read the body from a JSON file (`-` for stdin); -f fields win over its keys
#[arg(long, short = 'F', value_name = "PATH")]
from_file: Option<String>,
}

/// Prints the response body (pretty if JSON) to stdout, and fails on any
/// status that isn't a success, as `gh api` does, so scripts can check `$?`.
pub async fn run(args: ApiArgs, client: &Client) -> Result<(), String> {
let mut url = client.resolve(&args.path)?;
let fields = parse_fields(&args.raw_fields, &args.fields)?;
let body = match &args.input {
Some(path) => Some(read_json(path)?),
None => None,
};
for q in &args.query {
let (k, v) = split(q)?;
url.query_pairs_mut().append_pair(&k, &v);
}
let body = body(args.from_file.as_deref(), &args.fields)?;
let method = match &args.method {
Some(m) => m
.to_uppercase()
.parse::<Method>()
.map_err(|_| format!("Invalid method '{m}'"))?,
None if body.is_some() || !fields.is_empty() => Method::POST,
None if body.is_some() => Method::POST,
None => Method::GET,
};
if method == Method::GET && body.is_some() {
return Err(
"--input sends a request body, which a GET doesn't take: give query fields with -f/-F, or another method with -X"
"-f and -F send a request body, which a GET doesn't take: give query parameters with -q, or another method with -X"
.into(),
);
}

let body = if method == Method::GET {
let mut q = url.query_pairs_mut();
for (k, v) in &fields {
q.append_pair(k, &v.as_str().map_or_else(|| v.to_string(), String::from));
}
drop(q);
body
} else if fields.is_empty() {
body
} else {
Some(Value::Object(fields))
};

let (status, text) = client.send(method, url, body.as_ref()).await?;
match serde_json::from_str::<Value>(&text) {
Ok(json) => crate::output::json(&json),
Expand All @@ -82,28 +70,32 @@ pub async fn run(args: ApiArgs, client: &Client) -> Result<(), String> {
}
}

/// `-f` values are strings; `-F` values are JSON when they parse as a
/// boolean, null or number.
fn parse_fields(raw: &[String], typed: &[String]) -> Result<Map<String, Value>, String> {
let mut fields = Map::new();
let split = |f: &str| {
f.split_once('=')
.map(|(k, v)| (k.to_string(), v.to_string()))
.ok_or_else(|| format!("Expected KEY=VALUE, got '{f}'"))
/// The request body: `from_file`'s JSON with `fields` set over it. None when
/// neither is given.
fn body(from_file: Option<&str>, fields: &[String]) -> Result<Option<Value>, String> {
let mut body = match from_file {
Some(path) => read_json(path)?,
None if fields.is_empty() => return Ok(None),
None => Value::Object(Map::new()),
};
for f in raw {
let (k, v) = split(f)?;
fields.insert(k, Value::String(v));
if fields.is_empty() {
return Ok(Some(body));
}
for f in typed {
let Value::Object(map) = &mut body else {
return Err("-f fields can only be added to a JSON object from -F".into());
};
for f in fields {
let (k, v) = split(f)?;
let value = match serde_json::from_str::<Value>(&v) {
Ok(j @ (Value::Bool(_) | Value::Null | Value::Number(_))) => j,
_ => Value::String(v),
};
fields.insert(k, value);
let value = serde_json::from_str(&v).unwrap_or(Value::String(v));
map.insert(k, value);
}
Ok(fields)
Ok(Some(body))
}

fn split(f: &str) -> Result<(String, String), String> {
f.split_once('=')
.map(|(k, v)| (k.to_string(), v.to_string()))
.ok_or_else(|| format!("Expected KEY=VALUE, got '{f}'"))
}

/// The JSON in `path` (`-` for stdin).
Expand Down Expand Up @@ -131,40 +123,66 @@ mod tests {
ApiArgs {
path: path.into(),
method: None,
raw_fields: vec![],
query: vec![],
fields: vec![],
input: None,
from_file: None,
}
}

fn temp_json(name: &str, text: &str) -> String {
let path = std::env::temp_dir().join(format!("api-cmd-{name}-{}.json", std::process::id()));
std::fs::write(&path, text).unwrap();
path.display().to_string()
}

#[test]
fn typed_fields_become_json() {
let f = parse_fields(
&["n=5".into()],
&["a=true".into(), "b=null".into(), "c=7".into(), "d=x".into()],
)
.unwrap();
fn fields_are_json_when_they_parse() {
let fields = [
"a=true",
"b=null",
"c=7",
"d=x",
"e=[\"t\"]",
"f=\"5\"",
"g=a=b",
];
let fields: Vec<String> = fields.iter().map(|f| f.to_string()).collect();
assert_eq!(
Value::Object(f),
json!({"n": "5", "a": true, "b": null, "c": 7, "d": "x"})
body(None, &fields).unwrap(),
Some(json!({"a": true, "b": null, "c": 7, "d": "x", "e": ["t"], "f": "5", "g": "a=b"}))
);
assert!(parse_fields(&["nokey".into()], &[]).is_err());
assert_eq!(body(None, &[]).unwrap(), None);
assert!(body(None, &["nokey".into()]).is_err());
}

#[test]
fn fields_win_over_the_file() {
let path = temp_json("merge", r#"{"title": "Old", "description": "D"}"#);
let merged = body(Some(&path), &["title=New".into()]);
let list = temp_json("list", "[1]");
let not_object = body(Some(&list), &["title=New".into()]);
let _ = (std::fs::remove_file(&path), std::fs::remove_file(&list));
assert_eq!(
merged.unwrap(),
Some(json!({"title": "New", "description": "D"}))
);
assert!(not_object.is_err());
}

#[tokio::test]
async fn get_puts_fields_in_the_query() {
async fn query_parameters_keep_a_get() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/products/acct"))
.and(query_param("limit", "2"))
.and(query_param("q", "a b"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"items": []})))
.expect(1)
.mount(&server)
.await;
let client = Client::new(&server.uri(), None, false).unwrap();
let mut a = args("/products/acct");
a.method = Some("get".into());
a.fields = vec!["limit=2".into()];
a.query = vec!["limit=2".into(), "q=a b".into()];
run(a, &client).await.unwrap();
}

Expand All @@ -180,7 +198,7 @@ mod tests {
.await;
let client = Client::new(&server.uri(), None, false).unwrap();
let mut a = args("products/acct");
a.raw_fields = vec!["product_id=p".into(), "title=T".into()];
a.fields = vec!["product_id=p".into(), "title=T".into()];
run(a, &client).await.unwrap();
}

Expand All @@ -207,14 +225,11 @@ mod tests {

#[tokio::test]
async fn a_get_refuses_a_body() {
let path = std::env::temp_dir().join(format!("api-cmd-get-{}.json", std::process::id()));
std::fs::write(&path, "{}").unwrap();
let client = Client::new("http://127.0.0.1:9", None, false).unwrap();
let mut a = args("products/acct");
a.method = Some("GET".into());
a.input = Some(path.display().to_string());
a.fields = vec!["limit=2".into()];
let err = run(a, &client).await.unwrap_err();
let _ = std::fs::remove_file(&path);
assert!(err.starts_with("--input sends a request body"), "{err}");
assert!(err.starts_with("-f and -F send a request body"), "{err}");
}
}
24 changes: 22 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ For scripts and agents:
No prompts Without a terminal, or with SOURCE_PROMPT_DISABLED=1, nothing is asked;
give fields as flags or -F FILE (- for stdin), and --yes to delete.
SOURCE_TOKEN A bearer token for the API, used instead of the login session.
api Any API request; prints the API's JSON as is, without --json. Paths and
fields are in the OpenAPI spec at {api}/api/openapi.

Exit codes:
0 success 1 other failure 2 bad usage 3 input rejected (fix field_errors)
Expand All @@ -77,7 +79,7 @@ Run `source-coop <command> --help` for each command's flags and examples.";
name = "source-coop",
about = "Source Cooperative CLI",
version,
after_long_help = style::help(FOR_SCRIPTS)
after_long_help = style::help(&FOR_SCRIPTS.replace("{api}", defaults::API_URL))
)]
struct Cli {
/// Enable verbose output to see HTTP requests and responses
Expand All @@ -101,10 +103,28 @@ enum Commands {
Login(LoginArgs),
/// List, view, create, edit and delete products
Product(ProductArgs),
/// Make any request to the source.coop API, signed in as you
/// Send any request to the source.coop API as you, for what the other
/// commands don't cover
#[command(after_help = style::help(&API_HELP.replace("{api}", defaults::API_URL)))]
Api(ApiCommandArgs),
}

/// `source-coop api --help` after the flags: examples, then where to find
/// paths and what comes back.
const API_HELP: &str = "\
Examples:
source-coop api products/my-org -q limit=5
source-coop api products/my-org -f product_id=new -f title=New -f visibility=public -f data_connection_id=my-connection
source-coop api products/my-org/new -X PATCH -F changes.json -f title=Renamed
echo '{\"title\": \"Renamed\"}' | source-coop api products/my-org/new -X PATCH -F -

Paths, fields and responses:
The OpenAPI spec lists every path (under /api/v1) and its fields:
{api}/api/openapi
The response body is printed to stdout as the API sent it, JSON without --json.
A status other than success still prints it, and exits 3 when the input was
rejected, 4 when not signed in or not allowed, 5 when not found, 1 otherwise.";

#[derive(Parser)]
struct ApiCommandArgs {
/// source.coop site URL; the API is served under its `/api/v1`
Expand Down
Loading