//! REST: each route maps an HTTP request onto one operation.
use serde_json::{Map, Value};
use crate::operations::Op;
pub struct Route {
pub method: &'static str,
/// Segments starting with `:` are parameters.
pub path: &'static str,
pub op: Op,
/// Query parameters the route reads, as `(name in the URL, input name)`.
pub query: &'static [(&'static str, &'static str)],
}
const fn route(
method: &'static str,
path: &'static str,
op: Op,
query: &'static [(&'static str, &'static str)],
) -> Route {
Route {
method,
path,
op,
query,
}
}
pub const ROUTES: &[Route] = &[
route("GET", "/v1/user", Op::Whoami, &[]),
route("POST", "/v1/workspaces", Op::CreateWorkspace, &[]),
route("GET", "/v1/repos", Op::ListRepos, &[("q", "query")]),
route("POST", "/v1/repos", Op::CreateRepo, &[]),
route("GET", "/v1/repos/:owner/:name", Op::GetRepo, &[]),
route(
"GET",
"/v1/repos/:owner/:name/events",
Op::ListEvents,
&[("before", "before")],
),
route("GET", "/v1/repos/:owner/:name/labels", Op::ListLabels, &[]),
route(
"GET",
"/v1/repos/:owner/:name/issues",
Op::ListIssues,
&[("state", "state"), ("label", "label")],
),
route(
"POST",
"/v1/repos/:owner/:name/issues",
Op::CreateIssue,
&[],
),
route(
"GET",
"/v1/repos/:owner/:name/issues/:number",
Op::GetIssue,
&[],
),
route(
"PATCH",
"/v1/repos/:owner/:name/issues/:number",
Op::UpdateIssue,
&[],
),
route(
"POST",
"/v1/repos/:owner/:name/issues/:number/close",
Op::CloseIssue,
&[],
),
route(
"POST",
"/v1/repos/:owner/:name/issues/:number/reopen",
Op::ReopenIssue,
&[],
),
route(
"POST",
"/v1/repos/:owner/:name/issues/:number/comments",
Op::AddComment,
&[],
),
route(
"GET",
"/v1/repos/:owner/:name/pulls",
Op::ListPullRequests,
&[("state", "state")],
),
route(
"POST",
"/v1/repos/:owner/:name/pulls",
Op::CreatePullRequest,
&[],
),
route(
"GET",
"/v1/repos/:owner/:name/pulls/:number",
Op::GetPullRequest,
&[],
),
route(
"GET",
"/v1/repos/:owner/:name/pulls/:number/changes",
Op::GetPullRequestChanges,
&[],
),
route(
"POST",
"/v1/repos/:owner/:name/pulls/:number/reviews",
Op::ReviewPullRequest,
&[],
),
route(
"GET",
"/v1/repos/:owner/:name/pulls/:number/session",
Op::ReadSession,
&[("after", "after")],
),
route(
"POST",
"/v1/repos/:owner/:name/pulls/:number/session",
Op::RecordSession,
&[],
),
route(
"POST",
"/v1/repos/:owner/:name/pulls/:number/ready",
Op::MarkPullRequestReady,
&[],
),
route(
"POST",
"/v1/repos/:owner/:name/pulls/:number/close",
Op::ClosePullRequest,
&[],
),
route(
"POST",
"/v1/repos/:owner/:name/pulls/:number/merge",
Op::MergePullRequest,
&[],
),
];
impl Route {
/// The names of the route's path parameters, in order.
pub fn params(&self) -> impl Iterator<Item = &'static str> {
self.path
.split('/')
.filter_map(|segment| segment.strip_prefix(':'))
}
/// The values of the path parameters, if `path` is this route's.
fn matches<'a>(&self, path: &'a str) -> Option<Vec<(&'static str, &'a str)>> {
let mut values = Vec::new();
let mut actual = path.trim_end_matches('/').split('/');
for expected in self.path.split('/') {
let segment = actual.next()?;
match expected.strip_prefix(':') {
Some(name) if !segment.is_empty() => values.push((name, segment)),
Some(_) => return None,
None if expected == segment => {}
None => return None,
}
}
actual.next().is_none().then_some(values)
}
}
/// The route for a request, and the operation input it describes.
///
/// The input is the JSON body, overlaid with the query parameters the route
/// reads and then with what the path names: `owner` and `name` become
/// `repo`, and `number` becomes an integer.
pub fn resolve(
method: &str,
path: &str,
query: &[(String, String)],
body: Value,
) -> Option<(&'static Route, Value)> {
let (route, params) = ROUTES
.iter()
.filter(|route| route.method == method)
.find_map(|route| Some((route, route.matches(path)?)))?;
let mut input = match body {
Value::Object(fields) => fields,
_ => Map::new(),
};
for (name, key) in route.query {
if let Some((_, value)) = query.iter().find(|(query_name, _)| query_name == name) {
input.insert((*key).to_owned(), Value::String(value.clone()));
}
}
let param = |wanted: &str| {
params
.iter()
.find(|(name, _)| *name == wanted)
.map(|(_, value)| *value)
};
if let (Some(owner), Some(name)) = (param("owner"), param("name")) {
input.insert("repo".to_owned(), Value::String(format!("{owner}/{name}")));
}
if let Some(number) = param("number") {
// Not a number: zero, which no issue or pull request has.
input.insert(
"number".to_owned(),
number.parse::<u32>().unwrap_or(0).into(),
);
}
Some((route, Value::Object(input)))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn a_path_resolves_to_its_operation_and_input() {
let (route, input) = resolve(
"POST",
"/v1/repos/syntaqx/hello/pulls/14/merge",
&[],
json!({ "keep_issue_open": true, "number": 99, "repo": "someone/else" }),
)
.unwrap();
assert_eq!(route.op, Op::MergePullRequest);
// What the path names wins over the body.
assert_eq!(
input,
json!({ "keep_issue_open": true, "number": 14, "repo": "syntaqx/hello" })
);
}
#[test]
fn query_parameters_are_renamed() {
let query = [
("q".to_owned(), "parser".to_owned()),
("x".to_owned(), "y".to_owned()),
];
let (route, input) = resolve("GET", "/v1/repos", &query, Value::Null).unwrap();
assert_eq!(route.op, Op::ListRepos);
assert_eq!(input, json!({ "query": "parser" }));
}
#[test]
fn method_and_shape_must_match() {
assert!(resolve("GET", "/v1/repos/a/b/issues/1/close", &[], Value::Null).is_none());
assert!(resolve("GET", "/v1/repos/a", &[], Value::Null).is_none());
assert!(resolve("GET", "/v1/repos/a/b/issues/1/extra", &[], Value::Null).is_none());
assert!(resolve("GET", "/v1/repos/a/b/", &[], Value::Null).is_some());
}
#[test]
fn every_parameter_and_query_name_is_an_input() {
for route in ROUTES {
let properties = route.op.properties();
for (_, key) in route.query {
assert!(properties.contains_key(*key), "{}: {key}", route.path);
}
for name in route.params() {
let covered = matches!(name, "owner" | "name") && properties.contains_key("repo")
|| properties.contains_key(name);
assert!(covered, "{}: {name}", route.path);
}
}
}
#[test]
fn every_operation_has_a_route() {
for op in Op::ALL {
assert!(ROUTES.iter().any(|route| route.op == op), "{}", op.name());
}
}
}