use std::fmt;
use std::sync::Arc;
use http::Method;
use serde_json::{Map, Value};
use vorma_path_wire::{
DYNAMIC_PARAM_PREFIX, INDEX_SEGMENT, SPLAT_SEGMENT, canonicalize_pattern, encode_path_segment,
};
use crate::outcome::{RedirectingApiEndpoint, ResolveTarget};
use crate::plan::{ApiEndpointNode, ClassifiedRequest, CurrentPlan, Plan, ViewNode};
use crate::result::{ApiEndpointTarget, PageTarget, RedirectStatus};
use crate::search::{SearchShape, serialize_search_params};
#[derive(Clone)]
pub struct PageHrefs {
resolver: Arc<dyn ResolveTarget>,
}
impl PageHrefs {
pub(crate) fn new(resolver: Arc<dyn ResolveTarget>) -> Self {
Self { resolver }
}
pub fn from_resolver(
resolve_page_target: impl Fn(&PageTarget) -> Result<String, String> + Send + Sync + 'static,
) -> Self {
Self::new(Arc::new(FunctionResolver(resolve_page_target)))
}
pub fn to_href(&self, target: &PageTarget) -> Result<String, HrefError> {
self.resolver
.resolve_page_target(target)
.map_err(|problem| HrefError {
message: format!("the page target {:?} is invalid: {problem}", target.pattern),
})
}
}
struct FunctionResolver<Resolve>(Resolve);
impl<Resolve: Fn(&PageTarget) -> Result<String, String> + Send + Sync> ResolveTarget
for FunctionResolver<Resolve>
{
fn resolve_page_target(&self, target: &PageTarget) -> Result<String, String> {
(self.0)(target)
}
fn resolve_api_endpoint_target(
&self,
_redirecting: &RedirectingApiEndpoint,
_target: &ApiEndpointTarget,
_status: RedirectStatus,
) -> Result<String, String> {
Err("a PageHrefs made from a function resolves page targets only".to_owned())
}
}
impl fmt::Debug for PageHrefs {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.debug_struct("PageHrefs").finish_non_exhaustive()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HrefError {
message: String,
}
impl fmt::Display for HrefError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for HrefError {}
impl<State: Send + Sync + 'static> ResolveTarget for CurrentPlan<State> {
fn resolve_page_target(&self, target: &PageTarget) -> Result<String, String> {
self.get().resolve_page_target(target)
}
fn resolve_api_endpoint_target(
&self,
redirecting: &RedirectingApiEndpoint,
target: &ApiEndpointTarget,
status: RedirectStatus,
) -> Result<String, String> {
self.get()
.resolve_api_endpoint_target(redirecting, target, status)
}
}
impl<State: Send + Sync + 'static> ResolveTarget for Plan<State> {
fn resolve_page_target(&self, target: &PageTarget) -> Result<String, String> {
let node = self
.find_view_node(target.pattern)
.ok_or_else(|| "no view loader or view declares this pattern".to_owned())?;
let search = resolve_search(node, target)?;
let parts = HrefParts {
kind: PatternKind::View,
pattern: &node.pattern,
params: &target.params,
splat_values: &target.splat_values,
search,
hash: target.hash.as_deref(),
};
let mut href = build_path(&parts)?;
self.check_path_reaches_view(&href, node)?;
append_search_and_hash(&mut href, parts);
Ok(href)
}
fn resolve_api_endpoint_target(
&self,
redirecting: &RedirectingApiEndpoint,
target: &ApiEndpointTarget,
status: RedirectStatus,
) -> Result<String, String> {
let follow_up = follow_up_method(status, &redirecting.method);
let node = self
.find_api_endpoint_node(&follow_up, target.pattern)
.ok_or_else(|| {
format!(
"the follow-up request uses {follow_up}, and no {follow_up} API endpoint declares this pattern"
)
})?;
let types = &node.declaration.types;
if types.output != redirecting.types.output {
return Err(
"its output type differs from the redirecting endpoint's output type".to_owned(),
);
}
if types.declared_error != redirecting.types.declared_error {
return Err(
"its declared error type differs from the redirecting endpoint's declared error type"
.to_owned(),
);
}
let search = if follow_up == redirecting.method && follow_up != Method::GET {
if types.input != redirecting.types.input {
return Err(format!(
"the follow-up {follow_up} request carries this request's body, and its input type differs from the redirecting endpoint's input type"
));
}
if target.input.is_some() {
return Err(format!(
"the follow-up {follow_up} request carries this request's body, so the target takes no search input"
));
}
None
} else {
resolve_api_endpoint_search(node, target)?
};
let parts = HrefParts {
kind: PatternKind::ApiEndpoint,
pattern: node.declaration.pattern,
params: &target.params,
splat_values: &target.splat_values,
search,
hash: None,
};
let mut href = build_path(&parts)?;
append_search_and_hash(&mut href, parts);
Ok(href)
}
}
impl<State> Plan<State> {
fn check_path_reaches_view(&self, path: &str, node: &ViewNode<State>) -> Result<(), String> {
match self.classify(&Method::GET, path) {
Ok(ClassifiedRequest::View { positions })
if positions
.last()
.is_some_and(|leaf| std::ptr::eq(leaf.node, node)) =>
{
Ok(())
}
Ok(ClassifiedRequest::View { positions }) => Err(format!(
"the path {path:?} belongs to the view pattern {:?}",
positions
.last()
.map_or("", |leaf| leaf.node.pattern.as_str())
)),
Ok(ClassifiedRequest::ApiEndpoint { node, .. }) => Err(format!(
"the path {path:?} belongs to the GET API endpoint {:?}",
node.declaration.pattern
)),
Ok(ClassifiedRequest::Unmatched) => {
Err(format!("the path {path:?} matches no view pattern"))
}
Err(error) => Err(format!("the path {path:?} is not a valid path: {error}")),
}
}
}
fn follow_up_method(status: RedirectStatus, method: &Method) -> Method {
match status {
RedirectStatus::SeeOther => Method::GET,
RedirectStatus::Found if method == Method::POST => Method::GET,
RedirectStatus::Found
| RedirectStatus::TemporaryRedirect
| RedirectStatus::PermanentRedirect => method.clone(),
}
}
fn resolve_api_endpoint_search<State>(
node: &ApiEndpointNode<State>,
target: &ApiEndpointTarget,
) -> Result<Option<Map<String, Value>>, String> {
let input = match &target.input {
None => None,
Some(Ok(input)) => Some(input),
Some(Err(error)) => return Err(format!("the search input cannot be serialized: {error}")),
};
let Some(shape) = &node.search else {
return match input {
None => Ok(None),
Some(_) => Err("the endpoint takes no search input".to_owned()),
};
};
let empty = Map::new();
let object = match input {
None => &empty,
Some(Value::Object(object)) => object,
Some(_) => return Err("the search input is not an object".to_owned()),
};
let projected: Map<String, Value> = field_names(shape)
.filter_map(|name| {
object
.get(name)
.map(|value| (name.to_owned(), value.clone()))
})
.collect();
if !(node.declaration.is_search_input_valid_fn)(Value::Object(projected.clone())) {
return Err("the search input does not fit the endpoint's input type".to_owned());
}
Ok(Some(projected))
}
fn field_names(shape: &SearchShape) -> impl Iterator<Item = &str> {
let fields = match shape {
SearchShape::Object { fields, .. } => fields.as_slice(),
_ => &[],
};
fields.iter().map(|field| field.name.as_str())
}
fn resolve_search<State>(
node: &ViewNode<State>,
target: &PageTarget,
) -> Result<Option<Map<String, Value>>, String> {
let input = match &target.input {
None => None,
Some(Ok(input)) => Some(input),
Some(Err(error)) => return Err(format!("the search input cannot be serialized: {error}")),
};
if node.search_owners.is_empty() {
return match input {
None => Ok(None),
Some(_) => Err("no loader at or above this pattern declares search input".to_owned()),
};
}
let empty = Map::new();
let object = match input {
None => &empty,
Some(Value::Object(object)) => object,
Some(_) => return Err("the search input is not an object".to_owned()),
};
let mut search = Map::new();
for owner in &node.search_owners {
let Some(shape) = &owner.search else {
continue;
};
let projected: Map<String, Value> = field_names(shape)
.filter_map(|name| {
object
.get(name)
.map(|value| (name.to_owned(), value.clone()))
})
.collect();
if !(owner.declaration.is_search_input_valid_fn)(Value::Object(projected.clone())) {
return Err(format!(
"the search input does not satisfy the input of the view loader {:?}",
owner.declaration.pattern
));
}
search.extend(projected);
}
Ok(Some(search))
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum PatternKind {
View,
ApiEndpoint,
}
struct HrefParts<'a> {
kind: PatternKind,
pattern: &'a str,
params: &'a [(String, String)],
splat_values: &'a [String],
search: Option<Map<String, Value>>,
hash: Option<&'a str>,
}
fn build_path(parts: &HrefParts<'_>) -> Result<String, String> {
let spelled = canonicalize_pattern(parts.pattern).map_err(|error| error.to_string())?;
let has_trailing_slash =
parts.kind == PatternKind::ApiEndpoint && spelled.len() > 1 && spelled.ends_with('/');
let pattern_segments: Vec<&str> = if spelled == "/" {
Vec::new()
} else if has_trailing_slash {
spelled[1..spelled.len() - 1].split('/').collect()
} else {
spelled[1..].split('/').collect()
};
let mut trailing_slash = has_trailing_slash;
let mut segments = Vec::with_capacity(pattern_segments.len());
let mut has_splat = false;
for (index, segment) in pattern_segments.iter().enumerate() {
let is_last = index + 1 == pattern_segments.len();
if parts.kind == PatternKind::View && is_last && *segment == INDEX_SEGMENT {
continue;
}
if segment.len() == SPLAT_SEGMENT.len_utf8() && segment.starts_with(SPLAT_SEGMENT) {
has_splat = true;
if parts.splat_values.is_empty() {
match parts.kind {
PatternKind::View if pattern_segments.len() > 1 => {
return Err(format!(
"the view pattern {:?} cannot select its non-root splat with no values",
parts.pattern
));
}
PatternKind::ApiEndpoint if !segments.is_empty() => trailing_slash = true,
PatternKind::View | PatternKind::ApiEndpoint => {}
}
}
for value in parts.splat_values {
segments.push(encode_path_segment(value).map_err(|error| error.to_string())?);
}
continue;
}
if let Some(name) = segment.strip_prefix(DYNAMIC_PARAM_PREFIX) {
let value = parts
.params
.iter()
.find(|(param, _)| param == name)
.map(|(_, value)| value)
.ok_or_else(|| format!("the parameter {name} is missing"))?;
segments.push(encode_path_segment(value).map_err(|error| error.to_string())?);
continue;
}
segments.push((*segment).to_owned());
}
if !has_splat && !parts.splat_values.is_empty() {
return Err(format!(
"the pattern {:?} does not accept splat values",
parts.pattern
));
}
Ok(if segments.is_empty() {
"/".to_owned()
} else if trailing_slash {
format!("/{}/", segments.join("/"))
} else {
format!("/{}", segments.join("/"))
})
}
fn append_search_and_hash(href: &mut String, parts: HrefParts<'_>) {
if let Some(search) = parts.search.filter(|search| !search.is_empty()) {
let entries = serialize_search_params(&Value::Object(search));
let mut serializer = form_urlencoded::Serializer::new(String::new());
serializer.extend_pairs(&entries);
let query = serializer.finish();
if !query.is_empty() {
href.push('?');
href.push_str(&query);
}
}
if let Some(hash) = parts.hash.filter(|hash| !hash.is_empty()) {
href.push('#');
push_fragment(href, hash.strip_prefix('#').unwrap_or(hash));
}
}
fn push_fragment(href: &mut String, fragment: &str) {
for character in fragment.chars() {
if matches!(character, '\t' | '\n' | '\r') {
continue;
}
let encode = matches!(character, '\u{0}'..='\u{1F}' | ' ' | '"' | '<' | '>' | '`')
|| character > '\u{7E}';
if !encode {
href.push(character);
continue;
}
let mut buffer = [0; 4];
for byte in character.encode_utf8(&mut buffer).bytes() {
href.push_str(&format!("%{byte:02X}"));
}
}
}