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

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