pr_01m47d15m3e54sn21z27rpy5n9/apps/api/src/rest.rs

279 lines7,931 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", "/v1/user", Op::Whoami, &[]),
32 route("POST", "/v1/workspaces", Op::CreateWorkspace, &[]),
33 route("GET", "/v1/repos", Op::ListRepos, &[("q", "query")]),
34 route("POST", "/v1/repos", Op::CreateRepo, &[]),
35 route("GET", "/v1/repos/:owner/:name", Op::GetRepo, &[]),
36 route(
37 "GET",
38 "/v1/repos/:owner/:name/events",
39 Op::ListEvents,
40 &[("before", "before")],
41 ),
42 route("GET", "/v1/repos/:owner/:name/labels", Op::ListLabels, &[]),
43 route(
44 "GET",
45 "/v1/repos/:owner/:name/issues",
46 Op::ListIssues,
47 &[("state", "state"), ("label", "label")],
48 ),
49 route(
50 "POST",
51 "/v1/repos/:owner/:name/issues",
52 Op::CreateIssue,
53 &[],
54 ),
55 route(
56 "GET",
57 "/v1/repos/:owner/:name/issues/:number",
58 Op::GetIssue,
59 &[],
60 ),
61 route(
62 "PATCH",
63 "/v1/repos/:owner/:name/issues/:number",
64 Op::UpdateIssue,
65 &[],
66 ),
67 route(
68 "POST",
69 "/v1/repos/:owner/:name/issues/:number/close",
70 Op::CloseIssue,
71 &[],
72 ),
73 route(
74 "POST",
75 "/v1/repos/:owner/:name/issues/:number/reopen",
76 Op::ReopenIssue,
77 &[],
78 ),
79 route(
80 "POST",
81 "/v1/repos/:owner/:name/issues/:number/comments",
82 Op::AddComment,
83 &[],
84 ),
85 route(
86 "GET",
87 "/v1/repos/:owner/:name/pulls",
88 Op::ListPullRequests,
89 &[("state", "state")],
90 ),
91 route(
92 "POST",
93 "/v1/repos/:owner/:name/pulls",
94 Op::CreatePullRequest,
95 &[],
96 ),
97 route(
98 "GET",
99 "/v1/repos/:owner/:name/pulls/:number",
100 Op::GetPullRequest,
101 &[],
102 ),
103 route(
104 "GET",
105 "/v1/repos/:owner/:name/pulls/:number/changes",
106 Op::GetPullRequestChanges,
107 &[],
108 ),
109 route(
110 "POST",
111 "/v1/repos/:owner/:name/pulls/:number/reviews",
112 Op::ReviewPullRequest,
113 &[],
114 ),
115 route(
116 "GET",
117 "/v1/repos/:owner/:name/pulls/:number/session",
118 Op::ReadSession,
119 &[("after", "after")],
120 ),
121 route(
122 "POST",
123 "/v1/repos/:owner/:name/pulls/:number/session",
124 Op::RecordSession,
125 &[],
126 ),
127 route(
128 "POST",
129 "/v1/repos/:owner/:name/pulls/:number/ready",
130 Op::MarkPullRequestReady,
131 &[],
132 ),
133 route(
134 "POST",
135 "/v1/repos/:owner/:name/pulls/:number/close",
136 Op::ClosePullRequest,
137 &[],
138 ),
139 route(
140 "POST",
141 "/v1/repos/:owner/:name/pulls/:number/merge",
142 Op::MergePullRequest,
143 &[],
144 ),
145];
146
147impl Route {
148 /// The names of the route's path parameters, in order.
149 pub fn params(&self) -> impl Iterator<Item = &'static str> {
150 self.path
151 .split('/')
152 .filter_map(|segment| segment.strip_prefix(':'))
153 }
154
155 /// The values of the path parameters, if `path` is this route's.
156 fn matches<'a>(&self, path: &'a str) -> Option<Vec<(&'static str, &'a str)>> {
157 let mut values = Vec::new();
158 let mut actual = path.trim_end_matches('/').split('/');
159 for expected in self.path.split('/') {
160 let segment = actual.next()?;
161 match expected.strip_prefix(':') {
162 Some(name) if !segment.is_empty() => values.push((name, segment)),
163 Some(_) => return None,
164 None if expected == segment => {}
165 None => return None,
166 }
167 }
168 actual.next().is_none().then_some(values)
169 }
170}
171
172/// The route for a request, and the operation input it describes.
173///
174/// The input is the JSON body, overlaid with the query parameters the route
175/// reads and then with what the path names: `owner` and `name` become
176/// `repo`, and `number` becomes an integer.
177pub fn resolve(
178 method: &str,
179 path: &str,
180 query: &[(String, String)],
181 body: Value,
182) -> Option<(&'static Route, Value)> {
183 let (route, params) = ROUTES
184 .iter()
185 .filter(|route| route.method == method)
186 .find_map(|route| Some((route, route.matches(path)?)))?;
187
188 let mut input = match body {
189 Value::Object(fields) => fields,
190 _ => Map::new(),
191 };
192 for (name, key) in route.query {
193 if let Some((_, value)) = query.iter().find(|(query_name, _)| query_name == name) {
194 input.insert((*key).to_owned(), Value::String(value.clone()));
195 }
196 }
197 let param = |wanted: &str| {
198 params
199 .iter()
200 .find(|(name, _)| *name == wanted)
201 .map(|(_, value)| *value)
202 };
203 if let (Some(owner), Some(name)) = (param("owner"), param("name")) {
204 input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}")));
205 }
206 if let Some(number) = param("number") {
207 // Not a number: zero, which no issue or pull request has.
208 input.insert(
209 "number".to_owned(),
210 number.parse::<u32>().unwrap_or(0).into(),
211 );
212 }
213 Some((route, Value::Object(input)))
214}
215
216#[cfg(test)]
217mod tests {
218 use serde_json::json;
219
220 use super::*;
221
222 #[test]
223 fn a_path_resolves_to_its_operation_and_input() {
224 let (route, input) = resolve(
225 "POST",
226 "/v1/repos/syntaqx/hello/pulls/14/merge",
227 &[],
228 json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }),
229 )
230 .unwrap();
231 assert_eq!(route.op, Op::MergePullRequest);
232 // What the path names wins over the body.
233 assert_eq!(
234 input,
235 json!({ "keep_issue_open": true, "number": 14, "repo": "syntaqx/hello" })
236 );
237 }
238
239 #[test]
240 fn query_parameters_are_renamed() {
241 let query = [
242 ("q".to_owned(), "parser".to_owned()),
243 ("x".to_owned(), "y".to_owned()),
244 ];
245 let (route, input) = resolve("GET", "/v1/repos", &query, Value::Null).unwrap();
246 assert_eq!(route.op, Op::ListRepos);
247 assert_eq!(input, json!({ "query": "parser" }));
248 }
249
250 #[test]
251 fn method_and_shape_must_match() {
252 assert!(resolve("GET", "/v1/repos/a/b/issues/1/close", &[], Value::Null).is_none());
253 assert!(resolve("GET", "/v1/repos/a", &[], Value::Null).is_none());
254 assert!(resolve("GET", "/v1/repos/a/b/issues/1/extra", &[], Value::Null).is_none());
255 assert!(resolve("GET", "/v1/repos/a/b/", &[], Value::Null).is_some());
256 }
257
258 #[test]
259 fn every_parameter_and_query_name_is_an_input() {
260 for route in ROUTES {
261 let properties = route.op.properties();
262 for (_, key) in route.query {
263 assert!(properties.contains_key(*key), "{}: {key}", route.path);
264 }
265 for name in route.params() {
266 let covered = matches!(name, "owner" | "name") && properties.contains_key("repo")
267 || properties.contains_key(name);
268 assert!(covered, "{}: {name}", route.path);
269 }
270 }
271 }
272
273 #[test]
274 fn every_operation_has_a_route() {
275 for op in Op::ALL {
276 assert!(ROUTES.iter().any(|route| route.op == op), "{}", op.name());
277 }
278 }
279}