| 1 | //! Every operation of the g1t API, as the CLI knows it. |
| 2 | //! |
| 3 | //! The table in `ops_table.rs` is generated from the API's own description, |
| 4 | //! `apps/docs/src/data/openapi.json`, which apps/api keeps current. A test |
| 5 | //! fails when the two differ; regenerate with |
| 6 | //! |
| 7 | //! ```sh |
| 8 | //! G1T_WRITE_OPS=1 cargo test -p g1t ops_table |
| 9 | //! ``` |
| 10 | //! |
| 11 | //! Commands never spell out a path: they name the operation, and |
| 12 | //! [`Client::call`](crate::client::Client::call) finds its method and path |
| 13 | //! here. So a path that changes in the API changes in the CLI when the table |
| 14 | //! is regenerated, and an operation that goes away fails the test that |
| 15 | //! checks every command's operations exist ([`crate::mapping`]). |
| 16 | |
| 17 | pub use crate::ops_table::OPERATIONS; |
| 18 | |
| 19 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 20 | pub enum In { |
| 21 | Path, |
| 22 | Query, |
| 23 | } |
| 24 | |
| 25 | #[derive(Debug)] |
| 26 | pub struct Param { |
| 27 | pub name: &'static str, |
| 28 | pub location: In, |
| 29 | /// string, integer, number, boolean or array. |
| 30 | pub kind: &'static str, |
| 31 | pub required: bool, |
| 32 | /// The values it takes, when they are a fixed few. |
| 33 | pub values: &'static [&'static str], |
| 34 | } |
| 35 | |
| 36 | #[derive(Debug)] |
| 37 | pub struct Field { |
| 38 | pub name: &'static str, |
| 39 | pub kind: &'static str, |
| 40 | pub required: bool, |
| 41 | pub values: &'static [&'static str], |
| 42 | } |
| 43 | |
| 44 | #[derive(Debug)] |
| 45 | pub struct Operation { |
| 46 | pub id: &'static str, |
| 47 | pub method: &'static str, |
| 48 | pub path: &'static str, |
| 49 | pub tag: &'static str, |
| 50 | pub summary: &'static str, |
| 51 | /// The token scope it needs, when it needs one. |
| 52 | pub scope: Option<&'static str>, |
| 53 | pub params: &'static [Param], |
| 54 | pub body: &'static [Field], |
| 55 | } |
| 56 | |
| 57 | impl Operation { |
| 58 | pub fn path_params(&self) -> impl Iterator<Item = &Param> { |
| 59 | self.params.iter().filter(|p| p.location == In::Path) |
| 60 | } |
| 61 | |
| 62 | pub fn query_params(&self) -> impl Iterator<Item = &Param> { |
| 63 | self.params.iter().filter(|p| p.location == In::Query) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | pub fn find(id: &str) -> Option<&'static Operation> { |
| 68 | OPERATIONS.iter().find(|op| op.id == id) |
| 69 | } |
| 70 | |
| 71 | /// The operation, which must exist: commands name only operations the |
| 72 | /// mapping test has checked. |
| 73 | pub fn get(id: &str) -> &'static Operation { |
| 74 | find(id).unwrap_or_else(|| panic!("no operation {id}: regenerate crates/g1t/src/ops_table.rs")) |
| 75 | } |
| 76 | |
| 77 | #[cfg(test)] |
| 78 | pub mod generate { |
| 79 | //! Writes `ops_table.rs` from the OpenAPI document. |
| 80 | |
| 81 | use serde_json::Value; |
| 82 | use std::fmt::Write as _; |
| 83 | |
| 84 | pub fn openapi() -> Value { |
| 85 | let path = concat!( |
| 86 | env!("CARGO_MANIFEST_DIR"), |
| 87 | "/../../apps/docs/src/data/openapi.json" |
| 88 | ); |
| 89 | let text = std::fs::read_to_string(path).expect("apps/docs/src/data/openapi.json"); |
| 90 | serde_json::from_str(&text).expect("openapi.json parses") |
| 91 | } |
| 92 | |
| 93 | fn kind(schema: &Value) -> String { |
| 94 | match &schema["type"] { |
| 95 | Value::String(s) => s.clone(), |
| 96 | Value::Array(list) => list |
| 97 | .iter() |
| 98 | .filter_map(Value::as_str) |
| 99 | .find(|t| *t != "null") |
| 100 | .unwrap_or("string") |
| 101 | .to_owned(), |
| 102 | _ if schema.get("oneOf").is_some() || schema.get("anyOf").is_some() => { |
| 103 | "string".to_owned() |
| 104 | } |
| 105 | _ => "object".to_owned(), |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | fn values(schema: &Value) -> Vec<String> { |
| 110 | schema["enum"] |
| 111 | .as_array() |
| 112 | .map(|list| { |
| 113 | list.iter() |
| 114 | .filter_map(Value::as_str) |
| 115 | .map(str::to_owned) |
| 116 | .collect() |
| 117 | }) |
| 118 | .unwrap_or_default() |
| 119 | } |
| 120 | |
| 121 | fn quoted(list: &[String]) -> String { |
| 122 | let inner: Vec<String> = list.iter().map(|v| format!("{v:?}")).collect(); |
| 123 | format!("&[{}]", inner.join(", ")) |
| 124 | } |
| 125 | |
| 126 | pub fn table(doc: &Value) -> String { |
| 127 | struct Row { |
| 128 | tag: String, |
| 129 | id: String, |
| 130 | text: String, |
| 131 | } |
| 132 | let mut rows = Vec::new(); |
| 133 | for (path, item) in doc["paths"].as_object().expect("paths") { |
| 134 | for (method, op) in item.as_object().expect("path item") { |
| 135 | if !["get", "post", "put", "patch", "delete"].contains(&method.as_str()) { |
| 136 | continue; |
| 137 | } |
| 138 | let id = op["operationId"].as_str().expect("operationId").to_owned(); |
| 139 | let tag = op["tags"][0].as_str().unwrap_or("Other").to_owned(); |
| 140 | let summary = op["summary"].as_str().unwrap_or("").to_owned(); |
| 141 | let scope = match op["x-scope"].as_str() { |
| 142 | Some(s) => format!("Some({s:?})"), |
| 143 | None => "None".to_owned(), |
| 144 | }; |
| 145 | let mut text = String::new(); |
| 146 | writeln!(text, " Operation {{").unwrap(); |
| 147 | writeln!(text, " id: {id:?},").unwrap(); |
| 148 | writeln!(text, " method: {:?},", method.to_uppercase()).unwrap(); |
| 149 | writeln!(text, " path: {path:?},").unwrap(); |
| 150 | writeln!(text, " tag: {tag:?},").unwrap(); |
| 151 | writeln!(text, " summary: {summary:?},").unwrap(); |
| 152 | writeln!(text, " scope: {scope},").unwrap(); |
| 153 | let params: Vec<&Value> = op["parameters"] |
| 154 | .as_array() |
| 155 | .map(|l| l.iter().collect()) |
| 156 | .unwrap_or_default(); |
| 157 | if params.is_empty() { |
| 158 | writeln!(text, " params: &[],").unwrap(); |
| 159 | } else { |
| 160 | writeln!(text, " params: &[").unwrap(); |
| 161 | for p in params { |
| 162 | let location = match p["in"].as_str() { |
| 163 | Some("path") => "In::Path", |
| 164 | Some("query") => "In::Query", |
| 165 | _ => continue, |
| 166 | }; |
| 167 | writeln!( |
| 168 | text, |
| 169 | " Param {{ name: {:?}, location: {location}, kind: {:?}, required: {}, values: {} }},", |
| 170 | p["name"].as_str().unwrap_or(""), |
| 171 | kind(&p["schema"]), |
| 172 | p["required"].as_bool().unwrap_or(false), |
| 173 | quoted(&values(&p["schema"])), |
| 174 | ) |
| 175 | .unwrap(); |
| 176 | } |
| 177 | writeln!(text, " ],").unwrap(); |
| 178 | } |
| 179 | let schema = &op["requestBody"]["content"]["application/json"]["schema"]; |
| 180 | let required: Vec<&str> = schema["required"] |
| 181 | .as_array() |
| 182 | .map(|l| l.iter().filter_map(Value::as_str).collect()) |
| 183 | .unwrap_or_default(); |
| 184 | match schema["properties"].as_object() { |
| 185 | Some(props) if !props.is_empty() => { |
| 186 | writeln!(text, " body: &[").unwrap(); |
| 187 | for (name, field) in props { |
| 188 | writeln!( |
| 189 | text, |
| 190 | " Field {{ name: {name:?}, kind: {:?}, required: {}, values: {} }},", |
| 191 | kind(field), |
| 192 | required.contains(&name.as_str()), |
| 193 | quoted(&values(field)), |
| 194 | ) |
| 195 | .unwrap(); |
| 196 | } |
| 197 | writeln!(text, " ],").unwrap(); |
| 198 | } |
| 199 | _ => writeln!(text, " body: &[],").unwrap(), |
| 200 | } |
| 201 | writeln!(text, " }},").unwrap(); |
| 202 | rows.push(Row { tag, id, text }); |
| 203 | } |
| 204 | } |
| 205 | rows.sort_by(|a, b| (&a.tag, &a.id).cmp(&(&b.tag, &b.id))); |
| 206 | let mut out = String::from( |
| 207 | "// @generated from apps/docs/src/data/openapi.json by crates/g1t/src/ops.rs.\n\ |
| 208 | // Do not edit: run `G1T_WRITE_OPS=1 cargo test -p g1t ops_table`.\n\n\ |
| 209 | use crate::ops::{Field, In, Operation, Param};\n\n\ |
| 210 | #[rustfmt::skip]\n\ |
| 211 | pub static OPERATIONS: &[Operation] = &[\n", |
| 212 | ); |
| 213 | for row in rows { |
| 214 | out.push_str(&row.text); |
| 215 | } |
| 216 | out.push_str("];\n"); |
| 217 | out |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | #[cfg(test)] |
| 222 | mod tests { |
| 223 | use super::*; |
| 224 | |
| 225 | #[test] |
| 226 | fn ops_table_is_current() { |
| 227 | let doc = generate::openapi(); |
| 228 | let want = generate::table(&doc); |
| 229 | let path = concat!(env!("CARGO_MANIFEST_DIR"), "/src/ops_table.rs"); |
| 230 | if std::env::var_os("G1T_WRITE_OPS").is_some() { |
| 231 | std::fs::write(path, &want).unwrap(); |
| 232 | return; |
| 233 | } |
| 234 | let have = std::fs::read_to_string(path) |
| 235 | .unwrap_or_default() |
| 236 | .replace("\r\n", "\n"); |
| 237 | assert!( |
| 238 | have == want, |
| 239 | "crates/g1t/src/ops_table.rs is out of date with apps/docs/src/data/openapi.json: run G1T_WRITE_OPS=1 cargo test -p g1t ops_table" |
| 240 | ); |
| 241 | } |
| 242 | |
| 243 | #[test] |
| 244 | fn ids_are_unique() { |
| 245 | let mut seen = std::collections::HashSet::new(); |
| 246 | for op in OPERATIONS { |
| 247 | assert!(seen.insert(op.id), "{} twice", op.id); |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | #[test] |
| 252 | fn every_path_parameter_is_in_the_path() { |
| 253 | for op in OPERATIONS { |
| 254 | for p in op.path_params() { |
| 255 | assert!( |
| 256 | op.path.contains(&format!("{{{}}}", p.name)), |
| 257 | "{}: {}", |
| 258 | op.id, |
| 259 | p.name |
| 260 | ); |
| 261 | } |
| 262 | } |
| 263 | } |
| 264 | } |