pr_01m47d15m3e54sn21z27rpy5n9/apps/api/src/rest.rs
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| API and MCP server in Rust; a public index at the API root | 1 | //! REST: each route maps an HTTP request onto one operation. |
| 2 | ||
| 3 | use serde_json::{Map, Value}; | |
| 4 | ||
| 5 | use crate::operations::Op; | |
| 6 | ||
| 7 | pub struct Route { | |
| 8 | pub method: &'static str, | |
| 9 | /// Segments starting with `:` are parameters. | |
| 10 | pub path: &'static str, | |
| 11 | pub op: Op, | |
| 12 | /// Query parameters the route reads, as `(name in the URL, input name)`. | |
| 13 | pub query: &'static [(&'static str, &'static str)], | |
| 14 | } | |
| 15 | ||
| 16 | const fn route( | |
| 17 | method: &'static str, | |
| 18 | path: &'static str, | |
| 19 | op: Op, | |
| 20 | query: &'static [(&'static str, &'static str)], | |
| 21 | ) -> Route { | |
| 22 | Route { | |
| 23 | method, | |
| 24 | path, | |
| 25 | op, | |
| 26 | query, | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 30 | pub const ROUTES: &[Route] = &[ | |
| 31 | route("GET", "/v1/user", Op::Whoami, &[]), | |
| 32 | route("POST", "/v1/workspaces", Op::CreateWorkspace, &[]), | |
| 33 | route("GET", "/v1/repos", Op::ListRepos, &[("q", "query")]), | |
| 34 | route("POST", "/v1/repos", Op::CreateRepo, &[]), | |
| 35 | route("GET", "/v1/repos/:owner/:name", Op::GetRepo, &[]), | |
| 36 | route( | |
| 37 | "GET", | |
| 38 | "/v1/repos/:owner/:name/events", | |
| 39 | Op::ListEvents, | |
| 40 | &[("before", "before")], | |
| 41 | ), | |
| 42 | route("GET", "/v1/repos/:owner/:name/labels", Op::ListLabels, &[]), | |
| 43 | route( | |
| 44 | "GET", | |
| 45 | "/v1/repos/:owner/:name/issues", | |
| 46 | Op::ListIssues, | |
| 47 | &[("state", "state"), ("label", "label")], | |
| 48 | ), | |
| 49 | route( | |
| 50 | "POST", | |
| 51 | "/v1/repos/:owner/:name/issues", | |
| 52 | Op::CreateIssue, | |
| 53 | &[], | |
| 54 | ), | |
| 55 | route( | |
| 56 | "GET", | |
| 57 | "/v1/repos/:owner/:name/issues/:number", | |
| 58 | Op::GetIssue, | |
| 59 | &[], | |
| 60 | ), | |
| 61 | route( | |
| 62 | "PATCH", | |
| 63 | "/v1/repos/:owner/:name/issues/:number", | |
| 64 | Op::UpdateIssue, | |
| 65 | &[], | |
| 66 | ), | |
| 67 | route( | |
| 68 | "POST", | |
| 69 | "/v1/repos/:owner/:name/issues/:number/close", | |
| 70 | Op::CloseIssue, | |
| 71 | &[], | |
| 72 | ), | |
| 73 | route( | |
| 74 | "POST", | |
| 75 | "/v1/repos/:owner/:name/issues/:number/reopen", | |
| 76 | Op::ReopenIssue, | |
| 77 | &[], | |
| 78 | ), | |
| 79 | route( | |
| 80 | "POST", | |
| 81 | "/v1/repos/:owner/:name/issues/:number/comments", | |
| 82 | Op::AddComment, | |
| 83 | &[], | |
| 84 | ), | |
| 85 | route( | |
| 86 | "GET", | |
| 87 | "/v1/repos/:owner/:name/pulls", | |
| 88 | Op::ListPullRequests, | |
| 89 | &[("state", "state")], | |
| 90 | ), | |
| 91 | route( | |
| 92 | "POST", | |
| 93 | "/v1/repos/:owner/:name/pulls", | |
| 94 | Op::CreatePullRequest, | |
| 95 | &[], | |
| 96 | ), | |
| 97 | route( | |
| 98 | "GET", | |
| 99 | "/v1/repos/:owner/:name/pulls/:number", | |
| 100 | Op::GetPullRequest, | |
| 101 | &[], | |
| 102 | ), | |
| 103 | route( | |
| 104 | "GET", | |
| 105 | "/v1/repos/:owner/:name/pulls/:number/changes", | |
| 106 | Op::GetPullRequestChanges, | |
| 107 | &[], | |
| 108 | ), | |
| 109 | route( | |
| 110 | "GET", | |
| 111 | "/v1/repos/:owner/:name/pulls/:number/session", | |
| 112 | Op::ReadSession, | |
| 113 | &[("after", "after")], | |
| 114 | ), | |
| 115 | route( | |
| 116 | "POST", | |
| 117 | "/v1/repos/:owner/:name/pulls/:number/session", | |
| 118 | Op::RecordSession, | |
| 119 | &[], | |
| 120 | ), | |
| 121 | route( | |
| 122 | "POST", | |
| 123 | "/v1/repos/:owner/:name/pulls/:number/ready", | |
| 124 | Op::MarkPullRequestReady, | |
| 125 | &[], | |
| 126 | ), | |
| 127 | route( | |
| 128 | "POST", | |
| 129 | "/v1/repos/:owner/:name/pulls/:number/close", | |
| 130 | Op::ClosePullRequest, | |
| 131 | &[], | |
| 132 | ), | |
| 133 | route( | |
| 134 | "POST", | |
| 135 | "/v1/repos/:owner/:name/pulls/:number/merge", | |
| 136 | Op::MergePullRequest, | |
| 137 | &[], | |
| 138 | ), | |
| 139 | ]; | |
| 140 | ||
| 141 | impl Route { | |
| 142 | /// The names of the route's path parameters, in order. | |
| 143 | pub fn params(&self) -> impl Iterator<Item = &'static str> { | |
| 144 | self.path | |
| 145 | .split('/') | |
| 146 | .filter_map(|segment| segment.strip_prefix(':')) | |
| 147 | } | |
| 148 | ||
| 149 | /// The values of the path parameters, if `path` is this route's. | |
| 150 | fn matches<'a>(&self, path: &'a str) -> Option<Vec<(&'static str, &'a str)>> { | |
| 151 | let mut values = Vec::new(); | |
| 152 | let mut actual = path.trim_end_matches('/').split('/'); | |
| 153 | for expected in self.path.split('/') { | |
| 154 | let segment = actual.next()?; | |
| 155 | match expected.strip_prefix(':') { | |
| 156 | Some(name) if !segment.is_empty() => values.push((name, segment)), | |
| 157 | Some(_) => return None, | |
| 158 | None if expected == segment => {} | |
| 159 | None => return None, | |
| 160 | } | |
| 161 | } | |
| 162 | actual.next().is_none().then_some(values) | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| 166 | /// The route for a request, and the operation input it describes. | |
| 167 | /// | |
| 168 | /// The input is the JSON body, overlaid with the query parameters the route | |
| 169 | /// reads and then with what the path names: `owner` and `name` become | |
| 170 | /// `repo`, and `number` becomes an integer. | |
| 171 | pub fn resolve( | |
| 172 | method: &str, | |
| 173 | path: &str, | |
| 174 | query: &[(String, String)], | |
| 175 | body: Value, | |
| 176 | ) -> Option<(&'static Route, Value)> { | |
| 177 | let (route, params) = ROUTES | |
| 178 | .iter() | |
| 179 | .filter(|route| route.method == method) | |
| 180 | .find_map(|route| Some((route, route.matches(path)?)))?; | |
| 181 | ||
| 182 | let mut input = match body { | |
| 183 | Value::Object(fields) => fields, | |
| 184 | _ => Map::new(), | |
| 185 | }; | |
| 186 | for (name, key) in route.query { | |
| 187 | if let Some((_, value)) = query.iter().find(|(query_name, _)| query_name == name) { | |
| 188 | input.insert((*key).to_owned(), Value::String(value.clone())); | |
| 189 | } | |
| 190 | } | |
| 191 | let param = |wanted: &str| { | |
| 192 | params | |
| 193 | .iter() | |
| 194 | .find(|(name, _)| *name == wanted) | |
| 195 | .map(|(_, value)| *value) | |
| 196 | }; | |
| 197 | if let (Some(owner), Some(name)) = (param("owner"), param("name")) { | |
| 198 | input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}"))); | |
| 199 | } | |
| 200 | if let Some(number) = param("number") { | |
| 201 | // Not a number: zero, which no issue or pull request has. | |
| 202 | input.insert( | |
| 203 | "number".to_owned(), | |
| 204 | number.parse::<u32>().unwrap_or(0).into(), | |
| 205 | ); | |
| 206 | } | |
| 207 | Some((route, Value::Object(input))) | |
| 208 | } | |
| 209 | ||
| 210 | #[cfg(test)] | |
| 211 | mod tests { | |
| 212 | use serde_json::json; | |
| 213 | ||
| 214 | use super::*; | |
| 215 | ||
| 216 | #[test] | |
| 217 | fn a_path_resolves_to_its_operation_and_input() { | |
| 218 | let (route, input) = resolve( | |
| 219 | "POST", | |
| 220 | "/v1/repos/syntaqx/hello/pulls/14/merge", | |
| 221 | &[], | |
| 222 | json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }), | |
| 223 | ) | |
| 224 | .unwrap(); | |
| 225 | assert_eq!(route.op, Op::MergePullRequest); | |
| 226 | // What the path names wins over the body. | |
| 227 | assert_eq!( | |
| 228 | input, | |
| 229 | json!({ "keep_issue_open": true, "number": 14, "repo": "syntaqx/hello" }) | |
| 230 | ); | |
| 231 | } | |
| 232 | ||
| 233 | #[test] | |
| 234 | fn query_parameters_are_renamed() { | |
| 235 | let query = [ | |
| 236 | ("q".to_owned(), "parser".to_owned()), | |
| 237 | ("x".to_owned(), "y".to_owned()), | |
| 238 | ]; | |
| 239 | let (route, input) = resolve("GET", "/v1/repos", &query, Value::Null).unwrap(); | |
| 240 | assert_eq!(route.op, Op::ListRepos); | |
| 241 | assert_eq!(input, json!({ "query": "parser" })); | |
| 242 | } | |
| 243 | ||
| 244 | #[test] | |
| 245 | fn method_and_shape_must_match() { | |
| 246 | assert!(resolve("GET", "/v1/repos/a/b/issues/1/close", &[], Value::Null).is_none()); | |
| 247 | assert!(resolve("GET", "/v1/repos/a", &[], Value::Null).is_none()); | |
| 248 | assert!(resolve("GET", "/v1/repos/a/b/issues/1/extra", &[], Value::Null).is_none()); | |
| 249 | assert!(resolve("GET", "/v1/repos/a/b/", &[], Value::Null).is_some()); | |
| 250 | } | |
| 251 | ||
| 252 | #[test] | |
| 253 | fn every_parameter_and_query_name_is_an_input() { | |
| 254 | for route in ROUTES { | |
| 255 | let properties = route.op.properties(); | |
| 256 | for (_, key) in route.query { | |
| 257 | assert!(properties.contains_key(*key), "{}: {key}", route.path); | |
| 258 | } | |
| 259 | for name in route.params() { | |
| 260 | let covered = matches!(name, "owner" | "name") && properties.contains_key("repo") | |
| 261 | || properties.contains_key(name); | |
| 262 | assert!(covered, "{}: {name}", route.path); | |
| 263 | } | |
| 264 | } | |
| 265 | } | |
| 266 | ||
| 267 | #[test] | |
| 268 | fn every_operation_has_a_route() { | |
| 269 | for op in Op::ALL { | |
| 270 | assert!(ROUTES.iter().any(|route| route.op == op), "{}", op.name()); | |
| 271 | } | |
| 272 | } | |
| 273 | } |