Skip to content

g1t/apps/api/src/rest.rs

852 lines28,106 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("DELETE", "/workspaces/:workspace", Op::DeleteWorkspace, &[]),
34 route("GET", "/user/emails", Op::ListEmails, &[]),
35 route("POST", "/user/emails", Op::AddEmail, &[]),
36 route("DELETE", "/user/emails/:email", Op::RemoveEmail, &[]),
37 route("PATCH", "/user/email-settings", Op::UpdateEmailSettings, &[]),
38 route("GET", "/user/invites", Op::ListInvites, &[]),
39 route("POST", "/user/invites", Op::CreateInvite, &[]),
40 route("DELETE", "/user/invites/:id", Op::RevokeInvite, &[]),
41 route("GET", "/workspaces/:workspace/invitations", Op::ListWorkspaceInvites, &[]),
42 route("POST", "/workspaces/:workspace/invitations", Op::InviteMember, &[]),
43 route("DELETE", "/workspaces/:workspace/invitations/:id", Op::RevokeWorkspaceInvite, &[]),
44 // Who has access. GitHub's addresses, but for adding someone, which
45 // takes an email address as well as a username.
46 route("GET", "/repos/:owner/:name/collaborators", Op::ListCollaborators, &[]),
47 route("POST", "/repos/:owner/:name/collaborators", Op::AddCollaborator, &[]),
48 route("PATCH", "/repos/:owner/:name/collaborators/:username", Op::UpdateCollaborator, &[]),
49 route("DELETE", "/repos/:owner/:name/collaborators/:username", Op::RemoveCollaborator, &[]),
50 route(
51 "GET",
52 "/repos/:owner/:name/collaborators/:username/permission",
53 Op::GetCollaboratorPermission,
54 &[],
55 ),
56 route("GET", "/repos/:owner/:name/invitations", Op::ListRepoInvitations, &[]),
57 route("DELETE", "/repos/:owner/:name/invitations/:id", Op::RevokeRepoInvitation, &[]),
58 route("GET", "/user/repository_invitations", Op::ListMyRepoInvitations, &[]),
59 // Your notifications: threads, marking them, and what you subscribe
60 // to and watch. GitHub's addresses, with g1t's saved and snoozed.
61 route("GET", "/notifications", Op::ListNotifications, &[("all", "all"), ("participating", "participating"), ("view", "view"), ("reason", "reason"), ("severity", "severity"), ("since", "since"), ("before", "before"), ("cursor", "cursor"), ("per_page", "per_page")]),
62 route("PUT", "/notifications", Op::MarkNotificationsRead, &[]),
63 route("GET", "/notifications/threads/:id", Op::GetNotificationThread, &[]),
64 route("PATCH", "/notifications/threads/:id", Op::MarkThreadRead, &[]),
65 route("DELETE", "/notifications/threads/:id", Op::MarkThreadDone, &[]),
66 route("PUT", "/notifications/threads/:id/saved", Op::SaveThread, &[]),
67 route("DELETE", "/notifications/threads/:id/saved", Op::SaveThread, &[]),
68 route("PUT", "/notifications/threads/:id/snooze", Op::SnoozeThread, &[]),
69 route("DELETE", "/notifications/threads/:id/snooze", Op::SnoozeThread, &[]),
70 route("GET", "/notifications/threads/:id/subscription", Op::GetThreadSubscription, &[]),
71 route("PUT", "/notifications/threads/:id/subscription", Op::SetThreadSubscription, &[]),
72 route("DELETE", "/notifications/threads/:id/subscription", Op::DeleteThreadSubscription, &[]),
73 route("GET", "/repos/:owner/:name/notifications", Op::ListNotifications, &[("all", "all"), ("participating", "participating"), ("view", "view"), ("reason", "reason"), ("severity", "severity"), ("since", "since"), ("before", "before"), ("cursor", "cursor"), ("per_page", "per_page")]),
74 route("PUT", "/repos/:owner/:name/notifications", Op::MarkNotificationsRead, &[]),
75 route("GET", "/repos/:owner/:name/subscription", Op::GetRepoSubscription, &[]),
76 route("PUT", "/repos/:owner/:name/subscription", Op::SetRepoSubscription, &[]),
77 route("DELETE", "/repos/:owner/:name/subscription", Op::DeleteRepoSubscription, &[]),
78 route("GET", "/repos/:owner/:name/issues/:number/subscription", Op::GetThreadSubscription, &[]),
79 route("PUT", "/repos/:owner/:name/issues/:number/subscription", Op::SetThreadSubscription, &[]),
80 route("DELETE", "/repos/:owner/:name/issues/:number/subscription", Op::DeleteThreadSubscription, &[]),
81 route("GET", "/user/subscriptions", Op::ListWatchedRepos, &[]),
82 route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]),
83 route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]),
84 route("PATCH", "/workspaces/:workspace", Op::UpdateWorkspace, &[]),
85 route("PUT", "/workspaces/:workspace/base_permission", Op::SetBasePermission, &[]),
86 route(
87 "GET",
88 "/workspaces/:workspace/outside_collaborators",
89 Op::ListOutsideCollaborators,
90 &[],
91 ),
92 // Security alerts: secrets and vulnerable dependencies.
93 route(
94 "GET",
95 "/repos/:owner/:name/security/alerts",
96 Op::ListSecurityAlerts,
97 &[("state", "state"), ("kind", "kind")],
98 ),
99 route("POST", "/repos/:owner/:name/security/alerts/:id/dismiss", Op::DismissSecurityAlert, &[]),
100 route("POST", "/repos/:owner/:name/security/alerts/:id/reopen", Op::ReopenSecurityAlert, &[]),
101 route("GET", "/repos", Op::ListRepos, &[("q", "query")]),
102 route(
103 "GET",
104 "/search",
105 Op::Search,
106 &[("q", "query"), ("type", "type"), ("page", "page"), ("per_page", "per_page")],
107 ),
108 route("POST", "/repos", Op::CreateRepo, &[]),
109 route("GET", "/repos/:owner/:name", Op::GetRepo, &[]),
110 route("PATCH", "/repos/:owner/:name", Op::UpdateRepo, &[]),
111 route("POST", "/repos/:owner/:name/transfer", Op::TransferRepo, &[]),
112 route("DELETE", "/repos/:owner/:name", Op::DeleteRepo, &[]),
113 route(
114 "GET",
115 "/workspaces/:workspace/repos/deleted",
116 Op::ListDeletedRepos,
117 &[],
118 ),
119 route("POST", "/repos/:owner/:name/restore", Op::RestoreRepo, &[]),
120 route("POST", "/repos/:owner/:name/purge", Op::PurgeRepo, &[]),
121 route("POST", "/repos/:owner/:name/rename", Op::RenameRepo, &[]),
122 route("POST", "/repos/:owner/:name/archive", Op::ArchiveRepo, &[]),
123 route("POST", "/repos/:owner/:name/unarchive", Op::UnarchiveRepo, &[]),
124 route(
125 "POST",
126 "/repos/:owner/:name/visibility",
127 Op::SetRepoVisibility,
128 &[],
129 ),
130 route(
131 "POST",
132 "/repos/:owner/:name/branches/:branch/rename",
133 Op::RenameBranch,
134 &[],
135 ),
136 route(
137 "GET",
138 "/repos/:owner/:name/settings",
139 Op::GetRepoSettings,
140 &[],
141 ),
142 route(
143 "PATCH",
144 "/repos/:owner/:name/settings",
145 Op::UpdateRepoSettings,
146 &[],
147 ),
148 route("GET", "/repos/:owner/:name/check-names", Op::ListCheckNames, &[]),
149 route("GET", "/repos/:owner/:name/queue", Op::GetMergeQueue, &[]),
150 route(
151 "POST",
152 "/repos/:owner/:name/pulls/:number/messages",
153 Op::MessageAgent,
154 &[],
155 ),
156 route(
157 "POST",
158 "/repos/:owner/:name/pulls/:number/messages/take",
159 Op::TakeMessages,
160 &[],
161 ),
162 route(
163 "POST",
164 "/repos/:owner/:name/messages/:id/answer",
165 Op::AnswerMessage,
166 &[],
167 ),
168 route("POST", "/repos/:owner/:name/memory", Op::Remember, &[]),
169 route(
170 "GET",
171 "/repos/:owner/:name/memory",
172 Op::Recall,
173 &[("q", "query"), ("limit", "limit")],
174 ),
175 route(
176 "GET",
177 "/repos/:owner/:name/events",
178 Op::ListEvents,
179 &[("before", "before")],
180 ),
181 route("GET", "/repos/:owner/:name/labels", Op::ListLabels, &[]),
182 route(
183 "GET",
184 "/repos/:owner/:name/issues",
185 Op::ListIssues,
186 &[("state", "state"), ("label", "label")],
187 ),
188 route("POST", "/repos/:owner/:name/issues", Op::CreateIssue, &[]),
189 route(
190 "GET",
191 "/repos/:owner/:name/issues/:number",
192 Op::GetIssue,
193 &[],
194 ),
195 route(
196 "PATCH",
197 "/repos/:owner/:name/issues/:number",
198 Op::UpdateIssue,
199 &[],
200 ),
201 route(
202 "POST",
203 "/repos/:owner/:name/issues/:number/close",
204 Op::CloseIssue,
205 &[],
206 ),
207 route(
208 "POST",
209 "/repos/:owner/:name/issues/:number/reopen",
210 Op::ReopenIssue,
211 &[],
212 ),
213 route(
214 "POST",
215 "/repos/:owner/:name/issues/:number/assign",
216 Op::AssignIssue,
217 &[],
218 ),
219 route(
220 "POST",
221 "/repos/:owner/:name/issues/import",
222 Op::ImportIssue,
223 &[],
224 ),
225 route(
226 "POST",
227 "/repos/:owner/:name/issues/delegate",
228 Op::Delegate,
229 &[],
230 ),
231 route(
232 "GET",
233 "/repos/:owner/:name/context",
234 Op::GetContext,
235 &[("reference", "reference")],
236 ),
237 route(
238 "GET",
239 "/workspaces/:workspace/context/search",
240 Op::SearchContext,
241 &[("q", "query"), ("project", "project"), ("kinds", "kinds"), ("limit", "limit")],
242 ),
243 route(
244 "GET",
245 "/workspaces/:workspace/context/:kind/:id",
246 Op::GetEntity,
247 &[],
248 ),
249 route(
250 "GET",
251 "/workspaces/:workspace/integrations",
252 Op::ListIntegrations,
253 &[],
254 ),
255 route(
256 "POST",
257 "/workspaces/:workspace/integrations",
258 Op::ConnectIntegration,
259 &[],
260 ),
261 route(
262 "GET",
263 "/repos/:owner/:name/hooks",
264 Op::ListWebhooks,
265 &[],
266 ),
267 route(
268 "POST",
269 "/repos/:owner/:name/hooks",
270 Op::CreateWebhook,
271 &[],
272 ),
273 route(
274 "PATCH",
275 "/repos/:owner/:name/hooks/:id",
276 Op::UpdateWebhook,
277 &[],
278 ),
279 route(
280 "DELETE",
281 "/repos/:owner/:name/hooks/:id",
282 Op::DeleteWebhook,
283 &[],
284 ),
285 route(
286 "POST",
287 "/repos/:owner/:name/hooks/:id/pings",
288 Op::PingWebhook,
289 &[],
290 ),
291 route(
292 "GET",
293 "/repos/:owner/:name/hooks/:id/deliveries",
294 Op::ListWebhookDeliveries,
295 &[],
296 ),
297 route(
298 "POST",
299 "/repos/:owner/:name/hooks/:id/deliveries/:delivery/redeliver",
300 Op::RedeliverWebhook,
301 &[],
302 ),
303 route(
304 "GET",
305 "/workspaces/:workspace/hooks",
306 Op::ListWebhooks,
307 &[],
308 ),
309 route(
310 "POST",
311 "/workspaces/:workspace/hooks",
312 Op::CreateWebhook,
313 &[],
314 ),
315 route(
316 "PATCH",
317 "/workspaces/:workspace/hooks/:id",
318 Op::UpdateWebhook,
319 &[],
320 ),
321 route(
322 "DELETE",
323 "/workspaces/:workspace/hooks/:id",
324 Op::DeleteWebhook,
325 &[],
326 ),
327 route(
328 "POST",
329 "/workspaces/:workspace/hooks/:id/pings",
330 Op::PingWebhook,
331 &[],
332 ),
333 route(
334 "GET",
335 "/workspaces/:workspace/hooks/:id/deliveries",
336 Op::ListWebhookDeliveries,
337 &[],
338 ),
339 route(
340 "POST",
341 "/workspaces/:workspace/hooks/:id/deliveries/:delivery/redeliver",
342 Op::RedeliverWebhook,
343 &[],
344 ),
345 route(
346 "GET",
347 "/workspaces/:workspace/model-routes",
348 Op::GetModelRoutes,
349 &[],
350 ),
351 route(
352 "PUT",
353 "/workspaces/:workspace/model-routes",
354 Op::SetModelRoutes,
355 &[],
356 ),
357 route(
358 "DELETE",
359 "/workspaces/:workspace/integrations/:id",
360 Op::DisconnectIntegration,
361 &[],
362 ),
363 route(
364 "POST",
365 "/workspaces/:workspace/integrations/:id/test",
366 Op::TestIntegration,
367 &[],
368 ),
369 route(
370 "GET",
371 "/repos/:owner/:name/actions/workflows",
372 Op::ListWorkflows,
373 &[],
374 ),
375 route(
376 "GET",
377 "/repos/:owner/:name/actions/workflows/:workflow/runs",
378 Op::ListWorkflowRuns,
379 &[("branch", "branch"), ("event", "event"), ("per_page", "limit")],
380 ),
381 route(
382 "POST",
383 "/repos/:owner/:name/actions/workflows/:workflow/dispatches",
384 Op::DispatchWorkflow,
385 &[],
386 ),
387 route(
388 "PATCH",
389 "/repos/:owner/:name/actions/workflows/:workflow",
390 Op::UpdateWorkflow,
391 &[],
392 ),
393 route(
394 "PUT",
395 "/repos/:owner/:name/actions/workflows/:workflow/enable",
396 Op::UpdateWorkflow,
397 &[],
398 ),
399 route(
400 "PUT",
401 "/repos/:owner/:name/actions/workflows/:workflow/disable",
402 Op::UpdateWorkflow,
403 &[],
404 ),
405 route(
406 "GET",
407 "/repos/:owner/:name/actions/runs",
408 Op::ListWorkflowRuns,
409 &[("workflow", "workflow"), ("branch", "branch"), ("event", "event"), ("pull", "pull"), ("head_sha", "sha"), ("per_page", "limit")],
410 ),
411 route(
412 "GET",
413 "/repos/:owner/:name/actions/runs/:id",
414 Op::GetWorkflowRun,
415 &[],
416 ),
417 route(
418 "POST",
419 "/repos/:owner/:name/actions/runs/:id/cancel",
420 Op::CancelWorkflowRun,
421 &[],
422 ),
423 route(
424 "POST",
425 "/repos/:owner/:name/actions/runs/:id/rerun",
426 Op::RerunWorkflowRun,
427 &[],
428 ),
429 route(
430 "POST",
431 "/repos/:owner/:name/actions/runs/:id/rerun-failed-jobs",
432 Op::RerunWorkflowRun,
433 &[],
434 ),
435 route(
436 "GET",
437 "/repos/:owner/:name/actions/jobs/:job/logs",
438 Op::GetJobLogs,
439 &[("after", "after")],
440 ),
441 route(
442 "GET",
443 "/repos/:owner/:name/actions/secrets",
444 Op::ListActionsSecrets,
445 &[],
446 ),
447 route(
448 "PUT",
449 "/repos/:owner/:name/actions/secrets/:setting",
450 Op::SetActionsSecret,
451 &[],
452 ),
453 route(
454 "DELETE",
455 "/repos/:owner/:name/actions/secrets/:setting",
456 Op::DeleteActionsSecret,
457 &[],
458 ),
459 route(
460 "GET",
461 "/repos/:owner/:name/actions/variables",
462 Op::ListActionsVariables,
463 &[],
464 ),
465 route(
466 "POST",
467 "/repos/:owner/:name/actions/variables",
468 Op::SetActionsVariable,
469 &[],
470 ),
471 route(
472 "PATCH",
473 "/repos/:owner/:name/actions/variables/:setting",
474 Op::SetActionsVariable,
475 &[],
476 ),
477 route(
478 "DELETE",
479 "/repos/:owner/:name/actions/variables/:setting",
480 Op::DeleteActionsVariable,
481 &[],
482 ),
483 route(
484 "GET",
485 "/workspaces/:workspace/actions/secrets",
486 Op::ListActionsSecrets,
487 &[],
488 ),
489 route(
490 "PUT",
491 "/workspaces/:workspace/actions/secrets/:setting",
492 Op::SetActionsSecret,
493 &[],
494 ),
495 route(
496 "DELETE",
497 "/workspaces/:workspace/actions/secrets/:setting",
498 Op::DeleteActionsSecret,
499 &[],
500 ),
501 route(
502 "GET",
503 "/workspaces/:workspace/actions/variables",
504 Op::ListActionsVariables,
505 &[],
506 ),
507 route(
508 "POST",
509 "/workspaces/:workspace/actions/variables",
510 Op::SetActionsVariable,
511 &[],
512 ),
513 route(
514 "PATCH",
515 "/workspaces/:workspace/actions/variables/:setting",
516 Op::SetActionsVariable,
517 &[],
518 ),
519 route(
520 "DELETE",
521 "/workspaces/:workspace/actions/variables/:setting",
522 Op::DeleteActionsVariable,
523 &[],
524 ),
525 // Self-hosted runners: a repository's own, or a workspace's.
526 route("GET", "/repos/:owner/:name/actions/runners", Op::ListRunners, &[]),
527 route("POST", "/repos/:owner/:name/actions/runners/registration-token", Op::CreateRunnerRegistrationToken, &[]),
528 route("DELETE", "/repos/:owner/:name/actions/runners/:id", Op::RemoveRunner, &[]),
529 route("GET", "/repos/:owner/:name/actions/runner-settings", Op::GetRunnerSettings, &[]),
530 route("PATCH", "/repos/:owner/:name/actions/runner-settings", Op::UpdateRunnerSettings, &[]),
531 route("GET", "/workspaces/:workspace/actions/runners", Op::ListRunners, &[]),
532 route("POST", "/workspaces/:workspace/actions/runners/registration-token", Op::CreateRunnerRegistrationToken, &[]),
533 route("DELETE", "/workspaces/:workspace/actions/runners/:id", Op::RemoveRunner, &[]),
534 route("GET", "/workspaces/:workspace/actions/runner-settings", Op::GetRunnerSettings, &[]),
535 route("PATCH", "/workspaces/:workspace/actions/runner-settings", Op::UpdateRunnerSettings, &[]),
536 route("GET", "/workspaces/:workspace/actions/runner-groups", Op::ListRunnerGroups, &[]),
537 route("POST", "/workspaces/:workspace/actions/runner-groups", Op::CreateRunnerGroup, &[]),
538 route("PATCH", "/workspaces/:workspace/actions/runner-groups/:id", Op::UpdateRunnerGroup, &[]),
539 route("DELETE", "/workspaces/:workspace/actions/runner-groups/:id", Op::DeleteRunnerGroup, &[]),
540 route("POST", "/repos/:owner/:name/plans", Op::PlanWork, &[]),
541 route("GET", "/repos/:owner/:name/plans/:plan", Op::GetPlan, &[]),
542 route(
543 "POST",
544 "/repos/:owner/:name/plans/:plan/apply",
545 Op::ApplyPlan,
546 &[],
547 ),
548 route(
549 "POST",
550 "/repos/:owner/:name/issues/:number/comments",
551 Op::AddComment,
552 &[],
553 ),
554 route(
555 "GET",
556 "/repos/:owner/:name/pulls",
557 Op::ListPullRequests,
558 &[("state", "state")],
559 ),
560 route(
561 "POST",
562 "/repos/:owner/:name/pulls",
563 Op::CreatePullRequest,
564 &[],
565 ),
566 route(
567 "GET",
568 "/repos/:owner/:name/pulls/:number",
569 Op::GetPullRequest,
570 &[],
571 ),
572 route(
573 "GET",
574 "/repos/:owner/:name/pulls/:number/changes",
575 Op::GetPullRequestChanges,
576 &[],
577 ),
578 route(
579 "POST",
580 "/repos/:owner/:name/pulls/:number/reviews",
581 Op::ReviewPullRequest,
582 &[],
583 ),
584 route(
585 "GET",
586 "/repos/:owner/:name/pulls/:number/session",
587 Op::ReadSession,
588 &[("after", "after")],
589 ),
590 route(
591 "POST",
592 "/repos/:owner/:name/pulls/:number/session",
593 Op::RecordSession,
594 &[],
595 ),
596 route(
597 "POST",
598 "/repos/:owner/:name/pulls/:number/ready",
599 Op::MarkPullRequestReady,
600 &[],
601 ),
602 route(
603 "POST",
604 "/repos/:owner/:name/pulls/:number/close",
605 Op::ClosePullRequest,
606 &[],
607 ),
608 route(
609 "POST",
610 "/repos/:owner/:name/pulls/:number/merge",
611 Op::MergePullRequest,
612 &[],
613 ),
614];
615
616impl Route {
617 /// The names of the route's path parameters, in order.
618 pub fn params(&self) -> impl Iterator<Item = &'static str> {
619 self.path
620 .split('/')
621 .filter_map(|segment| segment.strip_prefix(':'))
622 }
623
624 /// The values of the path parameters, if `path` is this route's.
625 fn matches<'a>(&self, path: &'a str) -> Option<Vec<(&'static str, &'a str)>> {
626 let mut values = Vec::new();
627 let mut actual = path.trim_end_matches('/').split('/');
628 for expected in self.path.split('/') {
629 let segment = actual.next()?;
630 match expected.strip_prefix(':') {
631 Some(name) if !segment.is_empty() => values.push((name, segment)),
632 Some(_) => return None,
633 None if expected == segment => {}
634 None => return None,
635 }
636 }
637 actual.next().is_none().then_some(values)
638 }
639}
640
641/// A path segment with its `%XX` escapes decoded; as given when that is not
642/// UTF-8.
643fn percent_decoded(segment: &str) -> String {
644 let bytes = segment.as_bytes();
645 let mut out = Vec::with_capacity(bytes.len());
646 let mut i = 0;
647 while i < bytes.len() {
648 let escaped = (bytes[i] == b'%')
649 .then(|| segment.get(i + 1..i + 3))
650 .flatten()
651 .filter(|hex| hex.bytes().all(|byte| byte.is_ascii_hexdigit()))
652 .and_then(|hex| u8::from_str_radix(hex, 16).ok());
653 match escaped {
654 Some(byte) => {
655 out.push(byte);
656 i += 3;
657 }
658 None => {
659 out.push(bytes[i]);
660 i += 1;
661 }
662 }
663 }
664 String::from_utf8(out).unwrap_or_else(|_| segment.to_owned())
665}
666
667/// The route for a request, and the operation input it describes.
668///
669/// The input is the JSON body, overlaid with the query parameters the route
670/// reads and then with what the path names: `owner` and `name` become
671/// `repo`, and `number` becomes an integer.
672pub fn resolve(
673 method: &str,
674 path: &str,
675 query: &[(String, String)],
676 body: Value,
677) -> Option<(&'static Route, Value)> {
678 let (route, params) = ROUTES
679 .iter()
680 .filter(|route| route.method == method)
681 .find_map(|route| Some((route, route.matches(path)?)))?;
682
683 let mut input = match body {
684 Value::Object(fields) => fields,
685 _ => Map::new(),
686 };
687 for (name, key) in route.query {
688 if let Some((_, value)) = query.iter().find(|(query_name, _)| query_name == name) {
689 input.insert((*key).to_owned(), Value::String(value.clone()));
690 }
691 }
692 let param = |wanted: &str| {
693 params
694 .iter()
695 .find(|(name, _)| *name == wanted)
696 .map(|(_, value)| *value)
697 };
698 if let (Some(owner), Some(name)) = (param("owner"), param("name")) {
699 input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}")));
700 }
701 for key in ["plan", "id", "workspace", "delivery", "workflow", "job", "setting", "username"] {
702 if let Some(value) = param(key) {
703 input.insert(key.to_owned(), Value::String(value.to_owned()));
704 }
705 }
706 // A branch name may hold slashes, sent URL-encoded as one segment.
707 if let Some(branch) = param("branch") {
708 input.insert("branch".to_owned(), Value::String(percent_decoded(branch)));
709 }
710 // GitHub says some things with the path alone.
711 if route.path.ends_with("/enable") || route.path.ends_with("/disable") {
712 input.insert("enabled".to_owned(), Value::Bool(route.path.ends_with("/enable")));
713 }
714 if route.path.ends_with("/rerun-failed-jobs") {
715 input.insert("failed_only".to_owned(), Value::Bool(true));
716 }
717 // Unsaving and waking a thread are a DELETE of what PUT made.
718 if route.method == "DELETE" && route.path.ends_with("/saved") {
719 input.insert("saved".to_owned(), Value::Bool(false));
720 }
721 if route.method == "DELETE" && route.path.ends_with("/snooze") {
722 input.remove("until");
723 }
724 if let Some(number) = param("number") {
725 // Not a number: zero, which no issue or pull request has.
726 input.insert(
727 "number".to_owned(),
728 number.parse::<u32>().unwrap_or(0).into(),
729 );
730 }
731 Some((route, Value::Object(input)))
732}
733
734#[cfg(test)]
735mod tests {
736 use serde_json::json;
737
738 use super::*;
739
740 #[test]
741 fn a_path_resolves_to_its_operation_and_input() {
742 let (route, input) = resolve(
743 "POST",
744 "/repos/flagon-io/hello/pulls/14/merge",
745 &[],
746 json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }),
747 )
748 .unwrap();
749 assert_eq!(route.op, Op::MergePullRequest);
750 // What the path names wins over the body.
751 assert_eq!(
752 input,
753 json!({ "keep_issue_open": true, "number": 14, "repo": "flagon-io/hello" })
754 );
755 }
756
757 #[test]
758 fn a_branch_with_slashes_is_one_encoded_segment() {
759 let (route, input) = resolve(
760 "POST",
761 "/repos/flagon-io/hello/branches/feature%2Flogin/rename",
762 &[],
763 json!({ "new_name": "feature/sign-in" }),
764 )
765 .unwrap();
766 assert_eq!(route.op, Op::RenameBranch);
767 assert_eq!(
768 input,
769 json!({ "new_name": "feature/sign-in", "branch": "feature/login", "repo": "flagon-io/hello" })
770 );
771 assert_eq!(percent_decoded("100%"), "100%");
772 assert_eq!(percent_decoded("a%2bb%zz"), "a+b%zz");
773 }
774
775 #[test]
776 fn a_collaborator_is_named_by_username() {
777 let (route, input) = resolve(
778 "PATCH",
779 "/repos/flagon-io/hello/collaborators/ada",
780 &[],
781 json!({ "role": "maintain" }),
782 )
783 .unwrap();
784 assert_eq!(route.op, Op::UpdateCollaborator);
785 assert_eq!(input, json!({ "role": "maintain", "username": "ada", "repo": "flagon-io/hello" }));
786 let (route, input) = resolve("DELETE", "/user/repository_invitations/rin_1", &[], Value::Null).unwrap();
787 assert_eq!(route.op, Op::DeclineRepoInvitation);
788 assert_eq!(input, json!({ "id": "rin_1" }));
789 }
790
791 #[test]
792 fn notifications_are_addressed_as_threads_and_by_issue() {
793 let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1", &[], Value::Null).unwrap();
794 assert_eq!(route.op, Op::MarkThreadDone);
795 assert_eq!(input, json!({ "id": "ntf_1" }));
796 let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1/saved", &[], Value::Null).unwrap();
797 assert_eq!(route.op, Op::SaveThread);
798 assert_eq!(input, json!({ "id": "ntf_1", "saved": false }));
799 let (route, input) = resolve("DELETE", "/notifications/threads/ntf_1/snooze", &[], json!({ "until": "x" })).unwrap();
800 assert_eq!(route.op, Op::SnoozeThread);
801 assert_eq!(input, json!({ "id": "ntf_1" }));
802 let query = [("all".to_owned(), "true".to_owned()), ("per_page".to_owned(), "50".to_owned())];
803 let (route, input) = resolve("GET", "/repos/acme/rocket/notifications", &query, Value::Null).unwrap();
804 assert_eq!(route.op, Op::ListNotifications);
805 assert_eq!(input, json!({ "all": "true", "per_page": "50", "repo": "acme/rocket" }));
806 let (route, input) = resolve("PUT", "/repos/acme/rocket/issues/7/subscription", &[], json!({ "ignored": true })).unwrap();
807 assert_eq!(route.op, Op::SetThreadSubscription);
808 assert_eq!(input, json!({ "ignored": true, "number": 7, "repo": "acme/rocket" }));
809 assert_eq!(resolve("GET", "/user/subscriptions", &[], Value::Null).unwrap().0.op, Op::ListWatchedRepos);
810 }
811
812 #[test]
813 fn query_parameters_are_renamed() {
814 let query = [
815 ("q".to_owned(), "parser".to_owned()),
816 ("x".to_owned(), "y".to_owned()),
817 ];
818 let (route, input) = resolve("GET", "/repos", &query, Value::Null).unwrap();
819 assert_eq!(route.op, Op::ListRepos);
820 assert_eq!(input, json!({ "query": "parser" }));
821 }
822
823 #[test]
824 fn method_and_shape_must_match() {
825 assert!(resolve("GET", "/repos/a/b/issues/1/close", &[], Value::Null).is_none());
826 assert!(resolve("GET", "/repos/a", &[], Value::Null).is_none());
827 assert!(resolve("GET", "/repos/a/b/issues/1/extra", &[], Value::Null).is_none());
828 assert!(resolve("GET", "/repos/a/b/", &[], Value::Null).is_some());
829 }
830
831 #[test]
832 fn every_parameter_and_query_name_is_an_input() {
833 for route in ROUTES {
834 let properties = route.op.properties();
835 for (_, key) in route.query {
836 assert!(properties.contains_key(*key), "{}: {key}", route.path);
837 }
838 for name in route.params() {
839 let covered = matches!(name, "owner" | "name") && properties.contains_key("repo")
840 || properties.contains_key(name);
841 assert!(covered, "{}: {name}", route.path);
842 }
843 }
844 }
845
846 #[test]
847 fn every_operation_has_a_route() {
848 for op in Op::ALL {
849 assert!(ROUTES.iter().any(|route| route.op == op), "{}", op.name());
850 }
851 }
852}