g1t/apps/api/src/rest.rs

305 lines8,673 bytesCodeBlame

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 root1//! REST: each route maps an HTTP request onto one operation.
2
3use serde_json::{Map, Value};
4
5use crate::operations::Op;
6
7pub 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
16const 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
30pub const ROUTES: &[Route] = &[
Agents as a team: lifecycle, merge queue, billing and a new shell31 route("GET", "/user", Op::Whoami, &[]),
32 route("POST", "/workspaces", Op::CreateWorkspace, &[]),
33 route("GET", "/repos", Op::ListRepos, &[("q", "query")]),
34 route("POST", "/repos", Op::CreateRepo, &[]),
35 route("GET", "/repos/:owner/:name", Op::GetRepo, &[]),
36 route("PATCH", "/repos/:owner/:name", Op::UpdateRepo, &[]),
37 route(
38 "GET",
39 "/repos/:owner/:name/settings",
40 Op::GetRepoSettings,
41 &[],
42 ),
43 route(
44 "PATCH",
45 "/repos/:owner/:name/settings",
46 Op::UpdateRepoSettings,
47 &[],
48 ),
49 route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]),
API and MCP server in Rust; a public index at the API root50 route(
51 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell52 "/repos/:owner/:name/events",
API and MCP server in Rust; a public index at the API root53 Op::ListEvents,
54 &[("before", "before")],
55 ),
Agents as a team: lifecycle, merge queue, billing and a new shell56 route("GET", "/repos/:owner/:name/labels", Op::ListLabels, &[]),
API and MCP server in Rust; a public index at the API root57 route(
58 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell59 "/repos/:owner/:name/issues",
API and MCP server in Rust; a public index at the API root60 Op::ListIssues,
61 &[("state", "state"), ("label", "label")],
62 ),
Agents as a team: lifecycle, merge queue, billing and a new shell63 route("POST", "/repos/:owner/:name/issues", Op::CreateIssue, &[]),
API and MCP server in Rust; a public index at the API root64 route(
65 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell66 "/repos/:owner/:name/issues/:number",
API and MCP server in Rust; a public index at the API root67 Op::GetIssue,
68 &[],
69 ),
70 route(
71 "PATCH",
Agents as a team: lifecycle, merge queue, billing and a new shell72 "/repos/:owner/:name/issues/:number",
API and MCP server in Rust; a public index at the API root73 Op::UpdateIssue,
74 &[],
75 ),
76 route(
77 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell78 "/repos/:owner/:name/issues/:number/close",
API and MCP server in Rust; a public index at the API root79 Op::CloseIssue,
80 &[],
81 ),
82 route(
83 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell84 "/repos/:owner/:name/issues/:number/reopen",
API and MCP server in Rust; a public index at the API root85 Op::ReopenIssue,
86 &[],
87 ),
88 route(
89 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell90 "/repos/:owner/:name/issues/:number/assign",
91 Op::AssignIssue,
92 &[],
93 ),
94 route("POST", "/repos/:owner/:name/plans", Op::PlanWork, &[]),
95 route("GET", "/repos/:owner/:name/plans/:plan", Op::GetPlan, &[]),
96 route(
97 "POST",
98 "/repos/:owner/:name/plans/:plan/apply",
99 Op::ApplyPlan,
100 &[],
101 ),
102 route(
103 "POST",
104 "/repos/:owner/:name/issues/:number/comments",
API and MCP server in Rust; a public index at the API root105 Op::AddComment,
106 &[],
107 ),
108 route(
109 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell110 "/repos/:owner/:name/pulls",
API and MCP server in Rust; a public index at the API root111 Op::ListPullRequests,
112 &[("state", "state")],
113 ),
114 route(
115 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell116 "/repos/:owner/:name/pulls",
API and MCP server in Rust; a public index at the API root117 Op::CreatePullRequest,
118 &[],
119 ),
120 route(
121 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell122 "/repos/:owner/:name/pulls/:number",
API and MCP server in Rust; a public index at the API root123 Op::GetPullRequest,
124 &[],
125 ),
126 route(
127 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell128 "/repos/:owner/:name/pulls/:number/changes",
API and MCP server in Rust; a public index at the API root129 Op::GetPullRequestChanges,
130 &[],
131 ),
132 route(
Acceptance checks in sandboxes, line comments and review verdicts133 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell134 "/repos/:owner/:name/pulls/:number/reviews",
Acceptance checks in sandboxes, line comments and review verdicts135 Op::ReviewPullRequest,
136 &[],
137 ),
138 route(
API and MCP server in Rust; a public index at the API root139 "GET",
Agents as a team: lifecycle, merge queue, billing and a new shell140 "/repos/:owner/:name/pulls/:number/session",
API and MCP server in Rust; a public index at the API root141 Op::ReadSession,
142 &[("after", "after")],
143 ),
144 route(
145 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell146 "/repos/:owner/:name/pulls/:number/session",
API and MCP server in Rust; a public index at the API root147 Op::RecordSession,
148 &[],
149 ),
150 route(
151 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell152 "/repos/:owner/:name/pulls/:number/ready",
API and MCP server in Rust; a public index at the API root153 Op::MarkPullRequestReady,
154 &[],
155 ),
156 route(
157 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell158 "/repos/:owner/:name/pulls/:number/close",
API and MCP server in Rust; a public index at the API root159 Op::ClosePullRequest,
160 &[],
161 ),
162 route(
163 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell164 "/repos/:owner/:name/pulls/:number/merge",
API and MCP server in Rust; a public index at the API root165 Op::MergePullRequest,
166 &[],
167 ),
168];
169
170impl Route {
171 /// The names of the route's path parameters, in order.
172 pub fn params(&self) -> impl Iterator<Item = &'static str> {
173 self.path
174 .split('/')
175 .filter_map(|segment| segment.strip_prefix(':'))
176 }
177
178 /// The values of the path parameters, if `path` is this route's.
179 fn matches<'a>(&self, path: &'a str) -> Option<Vec<(&'static str, &'a str)>> {
180 let mut values = Vec::new();
181 let mut actual = path.trim_end_matches('/').split('/');
182 for expected in self.path.split('/') {
183 let segment = actual.next()?;
184 match expected.strip_prefix(':') {
185 Some(name) if !segment.is_empty() => values.push((name, segment)),
186 Some(_) => return None,
187 None if expected == segment => {}
188 None => return None,
189 }
190 }
191 actual.next().is_none().then_some(values)
192 }
193}
194
195/// The route for a request, and the operation input it describes.
196///
197/// The input is the JSON body, overlaid with the query parameters the route
198/// reads and then with what the path names: `owner` and `name` become
199/// `repo`, and `number` becomes an integer.
200pub fn resolve(
201 method: &str,
202 path: &str,
203 query: &[(String, String)],
204 body: Value,
205) -> Option<(&'static Route, Value)> {
206 let (route, params) = ROUTES
207 .iter()
208 .filter(|route| route.method == method)
209 .find_map(|route| Some((route, route.matches(path)?)))?;
210
211 let mut input = match body {
212 Value::Object(fields) => fields,
213 _ => Map::new(),
214 };
215 for (name, key) in route.query {
216 if let Some((_, value)) = query.iter().find(|(query_name, _)| query_name == name) {
217 input.insert((*key).to_owned(), Value::String(value.clone()));
218 }
219 }
220 let param = |wanted: &str| {
221 params
222 .iter()
223 .find(|(name, _)| *name == wanted)
224 .map(|(_, value)| *value)
225 };
226 if let (Some(owner), Some(name)) = (param("owner"), param("name")) {
227 input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}")));
228 }
Agents as a team: lifecycle, merge queue, billing and a new shell229 if let Some(plan) = param("plan") {
230 input.insert("plan".to_owned(), Value::String(plan.to_owned()));
231 }
API and MCP server in Rust; a public index at the API root232 if let Some(number) = param("number") {
233 // Not a number: zero, which no issue or pull request has.
234 input.insert(
235 "number".to_owned(),
236 number.parse::<u32>().unwrap_or(0).into(),
237 );
238 }
239 Some((route, Value::Object(input)))
240}
241
242#[cfg(test)]
243mod tests {
244 use serde_json::json;
245
246 use super::*;
247
248 #[test]
249 fn a_path_resolves_to_its_operation_and_input() {
250 let (route, input) = resolve(
251 "POST",
Agents as a team: lifecycle, merge queue, billing and a new shell252 "/repos/syntaqx/hello/pulls/14/merge",
API and MCP server in Rust; a public index at the API root253 &[],
254 json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }),
255 )
256 .unwrap();
257 assert_eq!(route.op, Op::MergePullRequest);
258 // What the path names wins over the body.
259 assert_eq!(
260 input,
261 json!({ "keep_issue_open": true, "number": 14, "repo": "syntaqx/hello" })
262 );
263 }
264
265 #[test]
266 fn query_parameters_are_renamed() {
267 let query = [
268 ("q".to_owned(), "parser".to_owned()),
269 ("x".to_owned(), "y".to_owned()),
270 ];
Agents as a team: lifecycle, merge queue, billing and a new shell271 let (route, input) = resolve("GET", "/repos", &query, Value::Null).unwrap();
API and MCP server in Rust; a public index at the API root272 assert_eq!(route.op, Op::ListRepos);
273 assert_eq!(input, json!({ "query": "parser" }));
274 }
275
276 #[test]
277 fn method_and_shape_must_match() {
Agents as a team: lifecycle, merge queue, billing and a new shell278 assert!(resolve("GET", "/repos/a/b/issues/1/close", &[], Value::Null).is_none());
279 assert!(resolve("GET", "/repos/a", &[], Value::Null).is_none());
280 assert!(resolve("GET", "/repos/a/b/issues/1/extra", &[], Value::Null).is_none());
281 assert!(resolve("GET", "/repos/a/b/", &[], Value::Null).is_some());
API and MCP server in Rust; a public index at the API root282 }
283
284 #[test]
285 fn every_parameter_and_query_name_is_an_input() {
286 for route in ROUTES {
287 let properties = route.op.properties();
288 for (_, key) in route.query {
289 assert!(properties.contains_key(*key), "{}: {key}", route.path);
290 }
291 for name in route.params() {
292 let covered = matches!(name, "owner" | "name") && properties.contains_key("repo")
293 || properties.contains_key(name);
294 assert!(covered, "{}: {name}", route.path);
295 }
296 }
297 }
298
299 #[test]
300 fn every_operation_has_a_route() {
301 for op in Op::ALL {
302 assert!(ROUTES.iter().any(|route| route.op == op), "{}", op.name());
303 }
304 }
305}