use syn::parse::{Parse, ParseStream};
use super::keywords::{method as method_keyword, option as keyword};
use super::list;
use super::method::RouteMethodKind;
use super::options;
#[derive(Debug)]
pub struct RoutesDeclaration {
pub visibility: syn::Visibility,
pub routes_fn_ident: syn::Ident,
pub routes_const_ident: syn::Ident,
pub route_mod_ident: syn::Ident,
pub state: Option<syn::Type>,
pub entries: Vec<RouteEntry>,
}
#[derive(Debug)]
pub enum RouteEntry {
Route(SingleRoute),
Group(RouteGroup),
Resource(ResourceDeclaration),
}
#[derive(Debug)]
pub struct SingleRoute {
pub method: RouteMethodKind,
pub path: String,
pub handler: syn::Path,
pub name: Option<String>,
pub pages: Vec<String>,
pub action: Option<syn::Path>,
pub query: Option<Box<syn::Type>>,
pub query_string: Option<syn::Path>,
pub policies: Vec<String>,
}
#[derive(Debug)]
pub struct RouteGroup {
pub prefix: String,
pub middleware: Vec<syn::Path>,
pub entries: Vec<RouteEntry>,
}
#[derive(Debug)]
pub struct ResourceDeclaration {
pub path: String,
pub controller: syn::Path,
pub name: String,
pub only: Vec<String>,
pub except: Vec<String>,
pub bind: Option<syn::Path>,
pub middleware: Vec<syn::Path>,
pub policies: Vec<String>,
}
impl Parse for RoutesDeclaration {
fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
let visibility: syn::Visibility = input.parse()?;
let name_ident: syn::Ident = input.parse()?;
let name = name_ident.to_string();
let span = name_ident.span();
let routes_fn_ident = syn::Ident::new(&format!("{name}_routes"), span);
let routes_const_ident = syn::Ident::new(&format!("{}_ROUTES", name.to_uppercase()), span);
let route_mod_ident = syn::Ident::new(&format!("{name}_route"), span);
let content;
syn::braced!(content in input);
let state = parse_state_clause(&content)?;
let mut entries = Vec::new();
while !content.is_empty() {
entries.push(parse_entry(&content)?);
let _: Option<syn::Token![,]> = content.parse()?;
}
Ok(RoutesDeclaration {
visibility,
routes_fn_ident,
routes_const_ident,
route_mod_ident,
state,
entries,
})
}
}
fn parse_state_clause(input: ParseStream<'_>) -> syn::Result<Option<syn::Type>> {
if !input.peek(keyword::state) {
return Ok(None);
}
let _: keyword::state = input.parse()?;
let _: syn::Token![:] = input.parse()?;
let ty: syn::Type = input.parse()?;
let _: syn::Token![;] = input.parse()?;
Ok(Some(ty))
}
fn parse_entry(input: ParseStream<'_>) -> syn::Result<RouteEntry> {
let lookahead = input.lookahead1();
let method = if lookahead.peek(method_keyword::get) {
input
.parse::<method_keyword::get>()
.map(|_| RouteMethodKind::Get)
} else if lookahead.peek(method_keyword::post) {
input
.parse::<method_keyword::post>()
.map(|_| RouteMethodKind::Post)
} else if lookahead.peek(method_keyword::put) {
input
.parse::<method_keyword::put>()
.map(|_| RouteMethodKind::Put)
} else if lookahead.peek(method_keyword::patch) {
input
.parse::<method_keyword::patch>()
.map(|_| RouteMethodKind::Patch)
} else if lookahead.peek(method_keyword::delete) {
input
.parse::<method_keyword::delete>()
.map(|_| RouteMethodKind::Delete)
} else if lookahead.peek(method_keyword::head) {
input
.parse::<method_keyword::head>()
.map(|_| RouteMethodKind::Head)
} else if lookahead.peek(method_keyword::options) {
input
.parse::<method_keyword::options>()
.map(|_| RouteMethodKind::Options)
} else if lookahead.peek(keyword::group) {
return parse_group(input).map(RouteEntry::Group);
} else if lookahead.peek(keyword::resource) {
return parse_resource(input).map(RouteEntry::Resource);
} else {
return Err(lookahead.error());
}?;
parse_route(input, method).map(RouteEntry::Route)
}
fn parse_route(input: ParseStream<'_>, method: RouteMethodKind) -> syn::Result<SingleRoute> {
let path_lit: syn::LitStr = input.parse()?;
let _: syn::Token![=>] = input.parse()?;
let handler: syn::Path = input.parse()?;
let options = options::parse(input)?;
Ok(SingleRoute {
method,
path: path_lit.value(),
handler,
name: options.name,
pages: options.pages,
action: options.action,
query: options.query.map(Box::new),
query_string: options.query_string,
policies: options.policies,
})
}
fn parse_group(input: ParseStream<'_>) -> syn::Result<RouteGroup> {
let _: keyword::group = input.parse()?;
let prefix_lit: syn::LitStr = input.parse()?;
let content;
syn::braced!(content in input);
let mut middleware = Vec::new();
let mut entries = Vec::new();
while !content.is_empty() {
if content.peek(keyword::middleware) {
let _: keyword::middleware = content.parse()?;
let _: syn::Token![:] = content.parse()?;
middleware = list::paths(&content)?;
let _: Option<syn::Token![;]> = content.parse()?;
} else {
entries.push(parse_entry(&content)?);
let _: Option<syn::Token![,]> = content.parse()?;
}
}
Ok(RouteGroup {
prefix: prefix_lit.value(),
middleware,
entries,
})
}
fn parse_resource(input: ParseStream<'_>) -> syn::Result<ResourceDeclaration> {
let _: keyword::resource = input.parse()?;
let path_lit: syn::LitStr = input.parse()?;
let _: syn::Token![=>] = input.parse()?;
let controller: syn::Path = input.parse()?;
let content;
syn::braced!(content in input);
let mut name = String::new();
let mut only = Vec::new();
let mut except = Vec::new();
let mut bind = None;
let mut middleware = Vec::new();
let mut policies = Vec::new();
while !content.is_empty() {
let lookahead = content.lookahead1();
if lookahead.peek(keyword::name) {
let _: keyword::name = content.parse()?;
let _: syn::Token![:] = content.parse()?;
name = list::dotted_name(&content)?;
} else if lookahead.peek(keyword::only) {
let _: keyword::only = content.parse()?;
let _: syn::Token![:] = content.parse()?;
only = list::idents(&content)?;
} else if lookahead.peek(keyword::except) {
let _: keyword::except = content.parse()?;
let _: syn::Token![:] = content.parse()?;
except = list::idents(&content)?;
} else if lookahead.peek(keyword::bind) {
let _: keyword::bind = content.parse()?;
let _: syn::Token![:] = content.parse()?;
bind = Some(content.parse()?);
} else if lookahead.peek(keyword::middleware) {
let _: keyword::middleware = content.parse()?;
let _: syn::Token![:] = content.parse()?;
middleware = list::paths(&content)?;
} else if lookahead.peek(keyword::policy) {
let _: keyword::policy = content.parse()?;
let _: syn::Token![:] = content.parse()?;
let path: syn::Path = content.parse()?;
policies = vec![super::type_name::final_segment(&path)];
} else if lookahead.peek(keyword::policies) {
let _: keyword::policies = content.parse()?;
let _: syn::Token![:] = content.parse()?;
policies = list::paths(&content)?
.iter()
.map(super::type_name::final_segment)
.collect();
} else {
return Err(lookahead.error());
}
let _: Option<syn::Token![,]> = content.parse()?;
}
if name.is_empty() {
return Err(syn::Error::new(
syn::spanned::Spanned::span(&controller),
"a resource requires a `name` option, e.g. \
`resource \"/links\" => LinksController { name: links }`",
));
}
Ok(ResourceDeclaration {
path: path_lit.value(),
controller,
name,
only,
except,
bind,
middleware,
policies,
})
}
#[cfg(test)]
mod tests {
use super::{RouteEntry, RoutesDeclaration};
use crate::routes::method::RouteMethodKind;
fn parse(tokens: proc_macro2::TokenStream) -> syn::Result<RoutesDeclaration> {
syn::parse2::<RoutesDeclaration>(tokens)
}
#[test]
fn generated_idents_follow_the_declaration_name() {
let decl = parse(quote::quote! {
pub app { get "/" => home { name: home } }
})
.unwrap();
assert_eq!(decl.routes_fn_ident.to_string(), "app_routes");
assert_eq!(decl.routes_const_ident.to_string(), "APP_ROUTES");
assert_eq!(decl.route_mod_ident.to_string(), "app_route");
}
#[test]
fn state_clause_is_optional() {
let decl = parse(quote::quote! { app { get "/" => home } }).unwrap();
assert!(decl.state.is_none());
}
#[test]
fn state_clause_is_captured() {
let decl = parse(quote::quote! {
app { state: AppState; get "/" => home }
})
.unwrap();
assert!(decl.state.is_some());
}
#[test]
fn every_method_keyword_opens_a_route() {
let decl = parse(quote::quote! {
app {
get "/a" => a
post "/b" => b
put "/c" => c
patch "/d" => d
delete "/e" => e
head "/f" => f
options "/g" => g
}
})
.unwrap();
assert_eq!(decl.entries.len(), 7);
let RouteEntry::Route(first) = &decl.entries[0] else {
panic!("expected a route");
};
assert_eq!(first.method, RouteMethodKind::Get);
}
#[test]
fn groups_nest() {
let decl = parse(quote::quote! {
app {
group "/auth" {
middleware: [Guest];
get "/login" => SessionsController::create { name: auth.login }
group "/admin" {
get "/panel" => panel { name: auth.admin.panel }
}
}
}
})
.unwrap();
let RouteEntry::Group(group) = &decl.entries[0] else {
panic!("expected a group");
};
assert_eq!(group.prefix, "/auth");
assert_eq!(group.middleware.len(), 1);
assert_eq!(group.entries.len(), 2);
}
#[test]
fn resource_options_are_captured() {
let decl = parse(quote::quote! {
app {
resource "/links" => LinksController {
name: links,
only: [index, show],
except: [edit],
bind: Link,
middleware: [Auth]
}
}
})
.unwrap();
let RouteEntry::Resource(resource) = &decl.entries[0] else {
panic!("expected a resource");
};
assert_eq!(resource.name, "links");
assert_eq!(resource.only, vec!["index".to_string(), "show".to_string()]);
assert_eq!(resource.except, vec!["edit".to_string()]);
assert!(resource.bind.is_some());
assert_eq!(resource.middleware.len(), 1);
}
#[test]
fn a_resource_without_a_name_is_rejected() {
let error = parse(quote::quote! {
app { resource "/links" => LinksController { only: [index] } }
})
.unwrap_err();
assert!(error.to_string().contains("requires a `name` option"));
}
#[test]
fn an_unknown_entry_keyword_is_rejected() {
let error = parse(quote::quote! { app { fetch "/" => home } }).unwrap_err();
assert!(!error.to_string().is_empty());
}
#[test]
fn query_and_query_string_reach_the_route() {
let decl = parse(quote::quote! {
app {
get "/links" => LinksController::index {
name: links.index,
query: Vec<LinkResource>,
query_string: LinkSearchRequest
}
}
})
.unwrap();
let RouteEntry::Route(route) = &decl.entries[0] else {
panic!("expected a route");
};
assert!(route.query.is_some());
assert!(route.query_string.is_some());
}
}