pr_01m47d24b0e6n91zwymwxg0vpx/apps/api/src/rest.rs

373 lines10,126 bytesCodeBlame
1//! 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] = &[
31 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, &[]),
50 route(
51 "POST",
52 "/repos/:owner/:name/pulls/:number/messages",
53 Op::MessageAgent,
54 &[],
55 ),
56 route(
57 "POST",
58 "/repos/:owner/:name/pulls/:number/messages/take",
59 Op::TakeMessages,
60 &[],
61 ),
62 route(
63 "POST",
64 "/repos/:owner/:name/messages/:id/answer",
65 Op::AnswerMessage,
66 &[],
67 ),
68 route(
69 "GET",
70 "/repos/:owner/:name/events",
71 Op::ListEvents,
72 &[("before", "before")],
73 ),
74 route("GET", "/repos/:owner/:name/labels", Op::ListLabels, &[]),
75 route(
76 "GET",
77 "/repos/:owner/:name/issues",
78 Op::ListIssues,
79 &[("state", "state"), ("label", "label")],
80 ),
81 route("POST", "/repos/:owner/:name/issues", Op::CreateIssue, &[]),
82 route(
83 "GET",
84 "/repos/:owner/:name/issues/:number",
85 Op::GetIssue,
86 &[],
87 ),
88 route(
89 "PATCH",
90 "/repos/:owner/:name/issues/:number",
91 Op::UpdateIssue,
92 &[],
93 ),
94 route(
95 "POST",
96 "/repos/:owner/:name/issues/:number/close",
97 Op::CloseIssue,
98 &[],
99 ),
100 route(
101 "POST",
102 "/repos/:owner/:name/issues/:number/reopen",
103 Op::ReopenIssue,
104 &[],
105 ),
106 route(
107 "POST",
108 "/repos/:owner/:name/issues/:number/assign",
109 Op::AssignIssue,
110 &[],
111 ),
112 route(
113 "POST",
114 "/repos/:owner/:name/issues/import",
115 Op::ImportIssue,
116 &[],
117 ),
118 route(
119 "GET",
120 "/repos/:owner/:name/context",
121 Op::GetContext,
122 &[("reference", "reference")],
123 ),
124 route(
125 "GET",
126 "/workspaces/:workspace/integrations",
127 Op::ListIntegrations,
128 &[],
129 ),
130 route(
131 "POST",
132 "/workspaces/:workspace/integrations",
133 Op::ConnectIntegration,
134 &[],
135 ),
136 route(
137 "GET",
138 "/workspaces/:workspace/model-routes",
139 Op::GetModelRoutes,
140 &[],
141 ),
142 route(
143 "PUT",
144 "/workspaces/:workspace/model-routes",
145 Op::SetModelRoutes,
146 &[],
147 ),
148 route(
149 "DELETE",
150 "/workspaces/:workspace/integrations/:id",
151 Op::DisconnectIntegration,
152 &[],
153 ),
154 route(
155 "POST",
156 "/workspaces/:workspace/integrations/:id/test",
157 Op::TestIntegration,
158 &[],
159 ),
160 route("POST", "/repos/:owner/:name/plans", Op::PlanWork, &[]),
161 route("GET", "/repos/:owner/:name/plans/:plan", Op::GetPlan, &[]),
162 route(
163 "POST",
164 "/repos/:owner/:name/plans/:plan/apply",
165 Op::ApplyPlan,
166 &[],
167 ),
168 route(
169 "POST",
170 "/repos/:owner/:name/issues/:number/comments",
171 Op::AddComment,
172 &[],
173 ),
174 route(
175 "GET",
176 "/repos/:owner/:name/pulls",
177 Op::ListPullRequests,
178 &[("state", "state")],
179 ),
180 route(
181 "POST",
182 "/repos/:owner/:name/pulls",
183 Op::CreatePullRequest,
184 &[],
185 ),
186 route(
187 "GET",
188 "/repos/:owner/:name/pulls/:number",
189 Op::GetPullRequest,
190 &[],
191 ),
192 route(
193 "GET",
194 "/repos/:owner/:name/pulls/:number/changes",
195 Op::GetPullRequestChanges,
196 &[],
197 ),
198 route(
199 "POST",
200 "/repos/:owner/:name/pulls/:number/reviews",
201 Op::ReviewPullRequest,
202 &[],
203 ),
204 route(
205 "GET",
206 "/repos/:owner/:name/pulls/:number/session",
207 Op::ReadSession,
208 &[("after", "after")],
209 ),
210 route(
211 "POST",
212 "/repos/:owner/:name/pulls/:number/session",
213 Op::RecordSession,
214 &[],
215 ),
216 route(
217 "POST",
218 "/repos/:owner/:name/pulls/:number/ready",
219 Op::MarkPullRequestReady,
220 &[],
221 ),
222 route(
223 "POST",
224 "/repos/:owner/:name/pulls/:number/close",
225 Op::ClosePullRequest,
226 &[],
227 ),
228 route(
229 "POST",
230 "/repos/:owner/:name/pulls/:number/merge",
231 Op::MergePullRequest,
232 &[],
233 ),
234];
235
236impl Route {
237 /// The names of the route's path parameters, in order.
238 pub fn params(&self) -> impl Iterator<Item = &'static str> {
239 self.path
240 .split('/')
241 .filter_map(|segment| segment.strip_prefix(':'))
242 }
243
244 /// The values of the path parameters, if `path` is this route's.
245 fn matches<'a>(&self, path: &'a str) -> Option<Vec<(&'static str, &'a str)>> {
246 let mut values = Vec::new();
247 let mut actual = path.trim_end_matches('/').split('/');
248 for expected in self.path.split('/') {
249 let segment = actual.next()?;
250 match expected.strip_prefix(':') {
251 Some(name) if !segment.is_empty() => values.push((name, segment)),
252 Some(_) => return None,
253 None if expected == segment => {}
254 None => return None,
255 }
256 }
257 actual.next().is_none().then_some(values)
258 }
259}
260
261/// The route for a request, and the operation input it describes.
262///
263/// The input is the JSON body, overlaid with the query parameters the route
264/// reads and then with what the path names: `owner` and `name` become
265/// `repo`, and `number` becomes an integer.
266pub fn resolve(
267 method: &str,
268 path: &str,
269 query: &[(String, String)],
270 body: Value,
271) -> Option<(&'static Route, Value)> {
272 let (route, params) = ROUTES
273 .iter()
274 .filter(|route| route.method == method)
275 .find_map(|route| Some((route, route.matches(path)?)))?;
276
277 let mut input = match body {
278 Value::Object(fields) => fields,
279 _ => Map::new(),
280 };
281 for (name, key) in route.query {
282 if let Some((_, value)) = query.iter().find(|(query_name, _)| query_name == name) {
283 input.insert((*key).to_owned(), Value::String(value.clone()));
284 }
285 }
286 let param = |wanted: &str| {
287 params
288 .iter()
289 .find(|(name, _)| *name == wanted)
290 .map(|(_, value)| *value)
291 };
292 if let (Some(owner), Some(name)) = (param("owner"), param("name")) {
293 input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}")));
294 }
295 for key in ["plan", "id", "workspace"] {
296 if let Some(value) = param(key) {
297 input.insert(key.to_owned(), Value::String(value.to_owned()));
298 }
299 }
300 if let Some(number) = param("number") {
301 // Not a number: zero, which no issue or pull request has.
302 input.insert(
303 "number".to_owned(),
304 number.parse::<u32>().unwrap_or(0).into(),
305 );
306 }
307 Some((route, Value::Object(input)))
308}
309
310#[cfg(test)]
311mod tests {
312 use serde_json::json;
313
314 use super::*;
315
316 #[test]
317 fn a_path_resolves_to_its_operation_and_input() {
318 let (route, input) = resolve(
319 "POST",
320 "/repos/syntaqx/hello/pulls/14/merge",
321 &[],
322 json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }),
323 )
324 .unwrap();
325 assert_eq!(route.op, Op::MergePullRequest);
326 // What the path names wins over the body.
327 assert_eq!(
328 input,
329 json!({ "keep_issue_open": true, "number": 14, "repo": "syntaqx/hello" })
330 );
331 }
332
333 #[test]
334 fn query_parameters_are_renamed() {
335 let query = [
336 ("q".to_owned(), "parser".to_owned()),
337 ("x".to_owned(), "y".to_owned()),
338 ];
339 let (route, input) = resolve("GET", "/repos", &query, Value::Null).unwrap();
340 assert_eq!(route.op, Op::ListRepos);
341 assert_eq!(input, json!({ "query": "parser" }));
342 }
343
344 #[test]
345 fn method_and_shape_must_match() {
346 assert!(resolve("GET", "/repos/a/b/issues/1/close", &[], Value::Null).is_none());
347 assert!(resolve("GET", "/repos/a", &[], Value::Null).is_none());
348 assert!(resolve("GET", "/repos/a/b/issues/1/extra", &[], Value::Null).is_none());
349 assert!(resolve("GET", "/repos/a/b/", &[], Value::Null).is_some());
350 }
351
352 #[test]
353 fn every_parameter_and_query_name_is_an_input() {
354 for route in ROUTES {
355 let properties = route.op.properties();
356 for (_, key) in route.query {
357 assert!(properties.contains_key(*key), "{}: {key}", route.path);
358 }
359 for name in route.params() {
360 let covered = matches!(name, "owner" | "name") && properties.contains_key("repo")
361 || properties.contains_key(name);
362 assert!(covered, "{}: {name}", route.path);
363 }
364 }
365 }
366
367 #[test]
368 fn every_operation_has_a_route() {
369 for op in Op::ALL {
370 assert!(ROUTES.iter().any(|route| route.op == op), "{}", op.name());
371 }
372 }
373}