g1t/apps/api/src/rest.rs

644 lines16,462 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 "/repos/:owner/:name/hooks",
139 Op::ListWebhooks,
140 &[],
141 ),
142 route(
143 "POST",
144 "/repos/:owner/:name/hooks",
145 Op::CreateWebhook,
146 &[],
147 ),
148 route(
149 "PATCH",
150 "/repos/:owner/:name/hooks/:id",
151 Op::UpdateWebhook,
152 &[],
153 ),
154 route(
155 "DELETE",
156 "/repos/:owner/:name/hooks/:id",
157 Op::DeleteWebhook,
158 &[],
159 ),
160 route(
161 "POST",
162 "/repos/:owner/:name/hooks/:id/pings",
163 Op::PingWebhook,
164 &[],
165 ),
166 route(
167 "GET",
168 "/repos/:owner/:name/hooks/:id/deliveries",
169 Op::ListWebhookDeliveries,
170 &[],
171 ),
172 route(
173 "POST",
174 "/repos/:owner/:name/hooks/:id/deliveries/:delivery/redeliver",
175 Op::RedeliverWebhook,
176 &[],
177 ),
178 route(
179 "GET",
180 "/workspaces/:workspace/hooks",
181 Op::ListWebhooks,
182 &[],
183 ),
184 route(
185 "POST",
186 "/workspaces/:workspace/hooks",
187 Op::CreateWebhook,
188 &[],
189 ),
190 route(
191 "PATCH",
192 "/workspaces/:workspace/hooks/:id",
193 Op::UpdateWebhook,
194 &[],
195 ),
196 route(
197 "DELETE",
198 "/workspaces/:workspace/hooks/:id",
199 Op::DeleteWebhook,
200 &[],
201 ),
202 route(
203 "POST",
204 "/workspaces/:workspace/hooks/:id/pings",
205 Op::PingWebhook,
206 &[],
207 ),
208 route(
209 "GET",
210 "/workspaces/:workspace/hooks/:id/deliveries",
211 Op::ListWebhookDeliveries,
212 &[],
213 ),
214 route(
215 "POST",
216 "/workspaces/:workspace/hooks/:id/deliveries/:delivery/redeliver",
217 Op::RedeliverWebhook,
218 &[],
219 ),
220 route(
221 "GET",
222 "/workspaces/:workspace/model-routes",
223 Op::GetModelRoutes,
224 &[],
225 ),
226 route(
227 "PUT",
228 "/workspaces/:workspace/model-routes",
229 Op::SetModelRoutes,
230 &[],
231 ),
232 route(
233 "DELETE",
234 "/workspaces/:workspace/integrations/:id",
235 Op::DisconnectIntegration,
236 &[],
237 ),
238 route(
239 "POST",
240 "/workspaces/:workspace/integrations/:id/test",
241 Op::TestIntegration,
242 &[],
243 ),
244 route(
245 "GET",
246 "/repos/:owner/:name/automations/runs",
247 Op::ListAutomationRuns,
248 &[("automation", "automation")],
249 ),
250 route(
251 "GET",
252 "/repos/:owner/:name/automations",
253 Op::ListAutomations,
254 &[],
255 ),
256 route(
257 "POST",
258 "/repos/:owner/:name/automations/:id/runs",
259 Op::RunAutomation,
260 &[],
261 ),
262 route(
263 "PATCH",
264 "/repos/:owner/:name/automations/:id",
265 Op::UpdateAutomation,
266 &[],
267 ),
268 route(
269 "GET",
270 "/repos/:owner/:name/actions/workflows",
271 Op::ListWorkflows,
272 &[],
273 ),
274 route(
275 "GET",
276 "/repos/:owner/:name/actions/workflows/:workflow/runs",
277 Op::ListWorkflowRuns,
278 &[("branch", "branch"), ("event", "event"), ("per_page", "limit")],
279 ),
280 route(
281 "POST",
282 "/repos/:owner/:name/actions/workflows/:workflow/dispatches",
283 Op::DispatchWorkflow,
284 &[],
285 ),
286 route(
287 "PATCH",
288 "/repos/:owner/:name/actions/workflows/:workflow",
289 Op::UpdateWorkflow,
290 &[],
291 ),
292 route(
293 "PUT",
294 "/repos/:owner/:name/actions/workflows/:workflow/enable",
295 Op::UpdateWorkflow,
296 &[],
297 ),
298 route(
299 "PUT",
300 "/repos/:owner/:name/actions/workflows/:workflow/disable",
301 Op::UpdateWorkflow,
302 &[],
303 ),
304 route(
305 "GET",
306 "/repos/:owner/:name/actions/runs",
307 Op::ListWorkflowRuns,
308 &[("workflow", "workflow"), ("branch", "branch"), ("event", "event"), ("pull", "pull"), ("head_sha", "sha"), ("per_page", "limit")],
309 ),
310 route(
311 "GET",
312 "/repos/:owner/:name/actions/runs/:id",
313 Op::GetWorkflowRun,
314 &[],
315 ),
316 route(
317 "POST",
318 "/repos/:owner/:name/actions/runs/:id/cancel",
319 Op::CancelWorkflowRun,
320 &[],
321 ),
322 route(
323 "POST",
324 "/repos/:owner/:name/actions/runs/:id/rerun",
325 Op::RerunWorkflowRun,
326 &[],
327 ),
328 route(
329 "POST",
330 "/repos/:owner/:name/actions/runs/:id/rerun-failed-jobs",
331 Op::RerunWorkflowRun,
332 &[],
333 ),
334 route(
335 "GET",
336 "/repos/:owner/:name/actions/jobs/:job/logs",
337 Op::GetJobLogs,
338 &[("after", "after")],
339 ),
340 route(
341 "GET",
342 "/repos/:owner/:name/actions/secrets",
343 Op::ListActionsSecrets,
344 &[],
345 ),
346 route(
347 "PUT",
348 "/repos/:owner/:name/actions/secrets/:setting",
349 Op::SetActionsSecret,
350 &[],
351 ),
352 route(
353 "DELETE",
354 "/repos/:owner/:name/actions/secrets/:setting",
355 Op::DeleteActionsSecret,
356 &[],
357 ),
358 route(
359 "GET",
360 "/repos/:owner/:name/actions/variables",
361 Op::ListActionsVariables,
362 &[],
363 ),
364 route(
365 "POST",
366 "/repos/:owner/:name/actions/variables",
367 Op::SetActionsVariable,
368 &[],
369 ),
370 route(
371 "PATCH",
372 "/repos/:owner/:name/actions/variables/:setting",
373 Op::SetActionsVariable,
374 &[],
375 ),
376 route(
377 "DELETE",
378 "/repos/:owner/:name/actions/variables/:setting",
379 Op::DeleteActionsVariable,
380 &[],
381 ),
382 route(
383 "GET",
384 "/workspaces/:workspace/actions/secrets",
385 Op::ListActionsSecrets,
386 &[],
387 ),
388 route(
389 "PUT",
390 "/workspaces/:workspace/actions/secrets/:setting",
391 Op::SetActionsSecret,
392 &[],
393 ),
394 route(
395 "DELETE",
396 "/workspaces/:workspace/actions/secrets/:setting",
397 Op::DeleteActionsSecret,
398 &[],
399 ),
400 route(
401 "GET",
402 "/workspaces/:workspace/actions/variables",
403 Op::ListActionsVariables,
404 &[],
405 ),
406 route(
407 "POST",
408 "/workspaces/:workspace/actions/variables",
409 Op::SetActionsVariable,
410 &[],
411 ),
412 route(
413 "PATCH",
414 "/workspaces/:workspace/actions/variables/:setting",
415 Op::SetActionsVariable,
416 &[],
417 ),
418 route(
419 "DELETE",
420 "/workspaces/:workspace/actions/variables/:setting",
421 Op::DeleteActionsVariable,
422 &[],
423 ),
424 route("POST", "/repos/:owner/:name/plans", Op::PlanWork, &[]),
425 route("GET", "/repos/:owner/:name/plans/:plan", Op::GetPlan, &[]),
426 route(
427 "POST",
428 "/repos/:owner/:name/plans/:plan/apply",
429 Op::ApplyPlan,
430 &[],
431 ),
432 route(
433 "POST",
434 "/repos/:owner/:name/issues/:number/comments",
435 Op::AddComment,
436 &[],
437 ),
438 route(
439 "GET",
440 "/repos/:owner/:name/pulls",
441 Op::ListPullRequests,
442 &[("state", "state")],
443 ),
444 route(
445 "POST",
446 "/repos/:owner/:name/pulls",
447 Op::CreatePullRequest,
448 &[],
449 ),
450 route(
451 "GET",
452 "/repos/:owner/:name/pulls/:number",
453 Op::GetPullRequest,
454 &[],
455 ),
456 route(
457 "GET",
458 "/repos/:owner/:name/pulls/:number/changes",
459 Op::GetPullRequestChanges,
460 &[],
461 ),
462 route(
463 "POST",
464 "/repos/:owner/:name/pulls/:number/reviews",
465 Op::ReviewPullRequest,
466 &[],
467 ),
468 route(
469 "GET",
470 "/repos/:owner/:name/pulls/:number/session",
471 Op::ReadSession,
472 &[("after", "after")],
473 ),
474 route(
475 "POST",
476 "/repos/:owner/:name/pulls/:number/session",
477 Op::RecordSession,
478 &[],
479 ),
480 route(
481 "POST",
482 "/repos/:owner/:name/pulls/:number/ready",
483 Op::MarkPullRequestReady,
484 &[],
485 ),
486 route(
487 "POST",
488 "/repos/:owner/:name/pulls/:number/close",
489 Op::ClosePullRequest,
490 &[],
491 ),
492 route(
493 "POST",
494 "/repos/:owner/:name/pulls/:number/merge",
495 Op::MergePullRequest,
496 &[],
497 ),
498];
499
500impl Route {
501 /// The names of the route's path parameters, in order.
502 pub fn params(&self) -> impl Iterator<Item = &'static str> {
503 self.path
504 .split('/')
505 .filter_map(|segment| segment.strip_prefix(':'))
506 }
507
508 /// The values of the path parameters, if `path` is this route's.
509 fn matches<'a>(&self, path: &'a str) -> Option<Vec<(&'static str, &'a str)>> {
510 let mut values = Vec::new();
511 let mut actual = path.trim_end_matches('/').split('/');
512 for expected in self.path.split('/') {
513 let segment = actual.next()?;
514 match expected.strip_prefix(':') {
515 Some(name) if !segment.is_empty() => values.push((name, segment)),
516 Some(_) => return None,
517 None if expected == segment => {}
518 None => return None,
519 }
520 }
521 actual.next().is_none().then_some(values)
522 }
523}
524
525/// The route for a request, and the operation input it describes.
526///
527/// The input is the JSON body, overlaid with the query parameters the route
528/// reads and then with what the path names: `owner` and `name` become
529/// `repo`, and `number` becomes an integer.
530pub fn resolve(
531 method: &str,
532 path: &str,
533 query: &[(String, String)],
534 body: Value,
535) -> Option<(&'static Route, Value)> {
536 let (route, params) = ROUTES
537 .iter()
538 .filter(|route| route.method == method)
539 .find_map(|route| Some((route, route.matches(path)?)))?;
540
541 let mut input = match body {
542 Value::Object(fields) => fields,
543 _ => Map::new(),
544 };
545 for (name, key) in route.query {
546 if let Some((_, value)) = query.iter().find(|(query_name, _)| query_name == name) {
547 input.insert((*key).to_owned(), Value::String(value.clone()));
548 }
549 }
550 let param = |wanted: &str| {
551 params
552 .iter()
553 .find(|(name, _)| *name == wanted)
554 .map(|(_, value)| *value)
555 };
556 if let (Some(owner), Some(name)) = (param("owner"), param("name")) {
557 input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}")));
558 }
559 for key in ["plan", "id", "workspace", "delivery", "workflow", "job", "setting"] {
560 if let Some(value) = param(key) {
561 input.insert(key.to_owned(), Value::String(value.to_owned()));
562 }
563 }
564 // GitHub says some things with the path alone.
565 if route.path.ends_with("/enable") || route.path.ends_with("/disable") {
566 input.insert("enabled".to_owned(), Value::Bool(route.path.ends_with("/enable")));
567 }
568 if route.path.ends_with("/rerun-failed-jobs") {
569 input.insert("failed_only".to_owned(), Value::Bool(true));
570 }
571 if let Some(number) = param("number") {
572 // Not a number: zero, which no issue or pull request has.
573 input.insert(
574 "number".to_owned(),
575 number.parse::<u32>().unwrap_or(0).into(),
576 );
577 }
578 Some((route, Value::Object(input)))
579}
580
581#[cfg(test)]
582mod tests {
583 use serde_json::json;
584
585 use super::*;
586
587 #[test]
588 fn a_path_resolves_to_its_operation_and_input() {
589 let (route, input) = resolve(
590 "POST",
591 "/repos/syntaqx/hello/pulls/14/merge",
592 &[],
593 json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }),
594 )
595 .unwrap();
596 assert_eq!(route.op, Op::MergePullRequest);
597 // What the path names wins over the body.
598 assert_eq!(
599 input,
600 json!({ "keep_issue_open": true, "number": 14, "repo": "syntaqx/hello" })
601 );
602 }
603
604 #[test]
605 fn query_parameters_are_renamed() {
606 let query = [
607 ("q".to_owned(), "parser".to_owned()),
608 ("x".to_owned(), "y".to_owned()),
609 ];
610 let (route, input) = resolve("GET", "/repos", &query, Value::Null).unwrap();
611 assert_eq!(route.op, Op::ListRepos);
612 assert_eq!(input, json!({ "query": "parser" }));
613 }
614
615 #[test]
616 fn method_and_shape_must_match() {
617 assert!(resolve("GET", "/repos/a/b/issues/1/close", &[], Value::Null).is_none());
618 assert!(resolve("GET", "/repos/a", &[], Value::Null).is_none());
619 assert!(resolve("GET", "/repos/a/b/issues/1/extra", &[], Value::Null).is_none());
620 assert!(resolve("GET", "/repos/a/b/", &[], Value::Null).is_some());
621 }
622
623 #[test]
624 fn every_parameter_and_query_name_is_an_input() {
625 for route in ROUTES {
626 let properties = route.op.properties();
627 for (_, key) in route.query {
628 assert!(properties.contains_key(*key), "{}: {key}", route.path);
629 }
630 for name in route.params() {
631 let covered = matches!(name, "owner" | "name") && properties.contains_key("repo")
632 || properties.contains_key(name);
633 assert!(covered, "{}: {name}", route.path);
634 }
635 }
636 }
637
638 #[test]
639 fn every_operation_has_a_route() {
640 for op in Op::ALL {
641 assert!(ROUTES.iter().any(|route| route.op == op), "{}", op.name());
642 }
643 }
644}