flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/api/src/rest.rs

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