use std::{
borrow::Cow,
fmt::{self, Display, Write},
};
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
use serde::Serialize;
use topcoat_core::{
base_url::base_url,
context::Cx,
url_form::{UrlForm, url_form},
};
use topcoat_view::{AttributeValueViewParts, NodeViewParts, PartsWriter};
use crate::{Path, PathSegment, PathSegments, request::uri};
pub trait HrefTarget {
fn path<'cx>(&self, cx: &'cx Cx) -> &'cx Path;
}
impl HrefTarget for &'static Path {
fn path<'cx>(&self, _cx: &'cx Cx) -> &'cx Path {
self
}
}
impl HrefTarget for &'static str {
fn path<'cx>(&self, cx: &'cx Cx) -> &'cx Path {
HrefTarget::path(&Path::new(self), cx)
}
}
impl<T> HrefTarget for &T
where
T: HrefTarget + ?Sized,
{
fn path<'cx>(&self, cx: &'cx Cx) -> &'cx Path {
(*self).path(cx)
}
}
pub trait HrefParam {
type Segment: ?Sized;
fn name(&self) -> &str;
fn segments(&self, segments: &mut HrefSegments<'_, Self::Segment>);
}
impl<T> HrefParam for &T
where
T: HrefParam,
{
type Segment = T::Segment;
fn name(&self) -> &str {
(*self).name()
}
fn segments(&self, segments: &mut HrefSegments<'_, Self::Segment>) {
(*self).segments(segments);
}
}
pub struct HrefSegments<'out, S: ?Sized> {
write: &'out mut dyn FnMut(&S),
count: usize,
}
impl<S: ?Sized> HrefSegments<'_, S> {
pub fn push(&mut self, segment: &S) {
self.count += 1;
(self.write)(segment);
}
}
const SEGMENT: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'/')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'`')
.add(b'{')
.add(b'}')
.add(b'\\');
struct PercentEncoded<'out>(&'out mut String);
impl Write for PercentEncoded<'_> {
fn write_str(&mut self, s: &str) -> fmt::Result {
self.0.extend(utf8_percent_encode(s, SEGMENT));
Ok(())
}
}
pub trait HrefParams {
fn assign(&self, path: &Path, out: &mut String);
}
macro_rules! impl_href_params_tuples {
( $($ty:ident),* ) => {
#[allow(non_snake_case, unused_mut, unused_variables)]
impl<$($ty,)*> HrefParams for ($($ty,)*)
where
$(
$ty: HrefParam,
<$ty as HrefParam>::Segment: Display,
)*
{
fn assign(&self, path: &Path, out: &mut String) {
let start = out.len();
let ($($ty,)*) = self;
let mut segments = path.segments();
$(
let (name, catch_all) = next_param(&mut segments, path, out);
assert_eq!(
name,
$ty.name(),
"provided parameter \"{}\" does not fill path parameter \
\"{name}\" in `{path}`",
$ty.name(),
);
push_segments($ty, name, catch_all, path, out);
)*
write_remaining(segments, path, out);
if out.len() == start {
out.push('/');
}
}
}
};
}
impl_href_params_tuples!();
impl_href_params_tuples!(P1);
impl_href_params_tuples!(P1, P2);
impl_href_params_tuples!(P1, P2, P3);
impl_href_params_tuples!(P1, P2, P3, P4);
impl_href_params_tuples!(P1, P2, P3, P4, P5);
impl_href_params_tuples!(P1, P2, P3, P4, P5, P6);
impl_href_params_tuples!(P1, P2, P3, P4, P5, P6, P7);
impl_href_params_tuples!(P1, P2, P3, P4, P5, P6, P7, P8);
fn next_param<'path>(
segments: &mut PathSegments<'path>,
path: &Path,
out: &mut String,
) -> (&'path str, bool) {
loop {
match segments.next() {
Some(PathSegment::Group(_)) => {}
Some(PathSegment::Static(segment)) => {
out.push('/');
out.push_str(segment);
}
Some(PathSegment::Param(name)) => return (name, false),
Some(PathSegment::CatchAll(name)) => return (name, true),
None => panic!("`{path}` declares fewer parameters than the href provides"),
}
}
}
fn push_segments<P>(param: &P, name: &str, catch_all: bool, path: &Path, out: &mut String)
where
P: HrefParam,
P::Segment: Display,
{
let mut write = |segment: &P::Segment| {
let start = out.len();
out.push('/');
write!(PercentEncoded(&mut *out), "{segment}").unwrap();
let rendered = &out[start + 1..];
assert!(
!rendered.is_empty(),
"parameter \"{name}\" in `{path}` was given an empty segment"
);
assert!(
!matches!(rendered, "." | ".."),
"parameter \"{name}\" in `{path}` was given \"{rendered}\", which \
addresses another path instead of filling a segment"
);
};
let mut segments = HrefSegments {
write: &mut write,
count: 0,
};
param.segments(&mut segments);
let count = segments.count;
if catch_all {
assert!(
count > 0,
"catch-all parameter \"{name}\" in `{path}` was given no segment"
);
} else {
assert!(
count == 1,
"parameter \"{name}\" fills one segment of `{path}`, but was given {count}"
);
}
}
fn write_remaining(segments: PathSegments<'_>, path: &Path, out: &mut String) {
for segment in segments {
match segment {
PathSegment::Group(_) => {}
PathSegment::Static(segment) => {
out.push('/');
out.push_str(segment);
}
PathSegment::Param(name) | PathSegment::CatchAll(name) => {
panic!("no value provided for path parameter \"{name}\" in `{path}`")
}
}
}
}
pub trait HrefQueries {
const SPECIFIED: bool;
fn assign(&self, out: &mut String);
}
impl HrefQueries for () {
const SPECIFIED: bool = false;
fn assign(&self, _out: &mut String) {}
}
macro_rules! impl_href_queries_tuples {
( $($ty:ident),+ ) => {
#[allow(non_snake_case, unused_assignments)]
impl<$($ty,)+> HrefQueries for ($($ty,)+)
where
$($ty: Serialize,)+
{
const SPECIFIED: bool = true;
fn assign(&self, out: &mut String) {
let ($($ty,)+) = self;
let mut separator = '?';
$(
if write_query($ty, separator, out) {
separator = '&';
}
)+
}
}
};
}
impl_href_queries_tuples!(Q1);
impl_href_queries_tuples!(Q1, Q2);
impl_href_queries_tuples!(Q1, Q2, Q3);
impl_href_queries_tuples!(Q1, Q2, Q3, Q4);
impl_href_queries_tuples!(Q1, Q2, Q3, Q4, Q5);
impl_href_queries_tuples!(Q1, Q2, Q3, Q4, Q5, Q6);
impl_href_queries_tuples!(Q1, Q2, Q3, Q4, Q5, Q6, Q7);
impl_href_queries_tuples!(Q1, Q2, Q3, Q4, Q5, Q6, Q7, Q8);
fn write_query<Q: Serialize>(query: &Q, separator: char, out: &mut String) -> bool {
let query = serde_urlencoded::to_string(query)
.unwrap_or_else(|error| panic!("query item does not serialize to a query string: {error}"));
if query.is_empty() {
return false;
}
out.push(separator);
out.push_str(&query);
true
}
#[must_use]
pub fn href<T, P>(target: T, params: P) -> Href<T, P, (), &'static str>
where
T: HrefTarget,
P: HrefParams,
{
Href {
target,
params,
queries: (),
url_form: None,
fragment: None,
}
}
#[macro_export]
macro_rules! href {
( $( $target:ident )::+ $(, $param:expr)* $(,)? ) => {
$crate::href(
<$($target)::+ as ::core::default::Default>::default(),
($($param,)*),
)
};
( $target:expr $(, $param:expr)* $(,)? ) => {
$crate::href($target, ($($param,)*))
};
}
pub struct Href<T, P, Q, F> {
target: T,
params: P,
queries: Q,
url_form: Option<UrlForm>,
fragment: Option<F>,
}
macro_rules! impl_href_query_methods {
( $($ty:ident),* ) => {
#[allow(non_snake_case)]
impl<T, P, $($ty,)* F> Href<T, P, ($($ty,)*), F> {
#[must_use]
pub fn query<Q>(self, query: Q) -> Href<T, P, ($($ty,)* Q,), F>
where
Q: Serialize,
{
let ($($ty,)*) = self.queries;
Href {
target: self.target,
params: self.params,
queries: ($($ty,)* query,),
url_form: self.url_form,
fragment: self.fragment,
}
}
}
};
}
impl_href_query_methods!();
impl_href_query_methods!(Q1);
impl_href_query_methods!(Q1, Q2);
impl_href_query_methods!(Q1, Q2, Q3);
impl_href_query_methods!(Q1, Q2, Q3, Q4);
impl_href_query_methods!(Q1, Q2, Q3, Q4, Q5);
impl_href_query_methods!(Q1, Q2, Q3, Q4, Q5, Q6);
impl_href_query_methods!(Q1, Q2, Q3, Q4, Q5, Q6, Q7);
impl<T, P, Q, F> Href<T, P, Q, F> {
#[must_use]
pub fn fragment<G>(self, fragment: G) -> Href<T, P, Q, G>
where
G: Display,
{
Href {
target: self.target,
params: self.params,
queries: self.queries,
url_form: self.url_form,
fragment: Some(fragment),
}
}
#[must_use]
pub fn relative(self) -> Self {
self.form(UrlForm::Relative)
}
#[must_use]
pub fn absolute(self) -> Self {
self.form(UrlForm::Absolute)
}
#[must_use]
pub fn form(mut self, url_form: UrlForm) -> Self {
self.url_form = Some(url_form);
self
}
}
impl<T, P, Q, F> Href<T, P, Q, F>
where
T: HrefTarget,
P: HrefParams,
Q: HrefQueries,
F: Display,
{
#[must_use]
pub fn resolve(&self, cx: &Cx) -> String {
let mut buf = String::new();
match self.url_form.unwrap_or_else(|| url_form(cx)) {
UrlForm::Absolute => buf += base_url(cx).as_str(),
UrlForm::Relative => {}
}
self.params.assign(self.target.path(cx), &mut buf);
self.queries.assign(&mut buf);
if let Some(fragment) = &self.fragment {
write!(buf, "#{fragment}").unwrap();
}
buf
}
#[must_use]
pub fn is_current(&self, cx: &Cx) -> bool {
fn query_pairs(query: &str) -> Vec<(Cow<'_, str>, Cow<'_, str>)> {
let mut pairs: Vec<_> = form_urlencoded::parse(query.as_bytes()).collect();
pairs.sort_unstable();
pairs
}
let uri = uri(cx);
let mut path = String::new();
self.params.assign(self.target.path(cx), &mut path);
if uri.path() != path {
return false;
}
if !Q::SPECIFIED {
return true;
}
let mut query = String::new();
self.queries.assign(&mut query);
query_pairs(query.strip_prefix('?').unwrap_or("")) == query_pairs(uri.query().unwrap_or(""))
}
}
impl<T, P, Q, F> NodeViewParts for Href<T, P, Q, F>
where
T: HrefTarget,
P: HrefParams,
Q: HrefQueries,
F: Display,
{
fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
parts.push_string(self.resolve(cx));
}
}
impl<T, P, Q, F> AttributeValueViewParts for Href<T, P, Q, F>
where
T: HrefTarget,
P: HrefParams,
Q: HrefQueries,
F: Display,
{
fn attribute_present(&self) -> bool {
true
}
fn into_view_parts(self, cx: &Cx, parts: &mut PartsWriter<'_>) {
parts.push_string(self.resolve(cx));
}
}
#[cfg(test)]
mod tests {
use topcoat_core::{base_url::BaseUrl, context::CxTestBuilder};
use super::*;
struct Param(&'static str, &'static str);
impl HrefParam for Param {
type Segment = str;
fn name(&self) -> &str {
self.0
}
fn segments(&self, segments: &mut HrefSegments<'_, str>) {
segments.push(self.1);
}
}
struct Tail(&'static str, &'static [&'static str]);
impl HrefParam for Tail {
type Segment = str;
fn name(&self) -> &str {
self.0
}
fn segments(&self, segments: &mut HrefSegments<'_, str>) {
for segment in self.1 {
segments.push(segment);
}
}
}
fn cx_with_base_url() -> Cx {
CxTestBuilder::new()
.app_context(BaseUrl::new("https://example.com").expect("a valid base URL"))
.build()
}
fn cx_with_uri(uri: &str) -> Cx {
let (parts, ()) = http::Request::builder()
.uri(uri)
.body(())
.expect("a valid request URI")
.into_parts();
CxTestBuilder::new().request_context(parts).build()
}
fn assign(path: &str, params: &impl HrefParams) -> String {
let mut out = String::new();
params.assign(Path::new(path), &mut out);
out
}
fn assign_queries(queries: &impl HrefQueries) -> String {
let mut out = String::new();
queries.assign(&mut out);
out
}
#[test]
fn writes_a_static_path_verbatim() {
assert_eq!(assign("/users/all", &()), "/users/all");
}
#[test]
fn writes_the_root_path_as_a_slash() {
assert_eq!(assign("/", &()), "/");
}
#[test]
fn fills_parameters_in_declaration_order() {
assert_eq!(
assign(
"/users/{id}/posts/{post_id}",
&(Param("id", "42"), Param("post_id", "7")),
),
"/users/42/posts/7"
);
}
#[test]
fn skips_group_segments() {
assert_eq!(
assign("/(auth)/users/{id}", &(Param("id", "42"),)),
"/users/42"
);
}
#[test]
fn a_group_only_path_addresses_the_root() {
assert_eq!(assign("/(marketing)", &()), "/");
}
#[test]
fn fills_a_catch_all_with_one_segment_per_element() {
assert_eq!(
assign("/docs/{*rest}", &(Tail("rest", &["guides", "start"]),)),
"/docs/guides/start"
);
}
#[test]
fn percent_encodes_a_segment() {
assert_eq!(
assign("/users/{id}", &(Param("id", "a/b?c#d e"),)),
"/users/a%2Fb%3Fc%23d%20e"
);
}
#[test]
fn percent_encodes_each_segment_of_a_catch_all() {
assert_eq!(
assign("/docs/{*rest}", &(Tail("rest", &["a/b", "caf\u{e9}"]),)),
"/docs/a%2Fb/caf%C3%A9"
);
}
#[test]
fn leaves_the_unreserved_characters_of_a_segment_alone() {
assert_eq!(
assign("/docs/{slug}", &(Param("slug", "getting-started_v1.0~x"),)),
"/docs/getting-started_v1.0~x"
);
}
#[test]
#[should_panic(expected = "provided parameter \"user_id\" does not fill path parameter \"id\"")]
fn rejects_a_parameter_name_mismatch() {
let _ = assign("/users/{id}", &(Param("user_id", "42"),));
}
#[test]
#[should_panic(
expected = "parameter \"id\" fills one segment of `/users/{id}`, but was given 2"
)]
fn rejects_a_parameter_spanning_several_segments() {
let _ = assign("/users/{id}", &(Tail("id", &["a", "b"]),));
}
#[test]
#[should_panic(
expected = "catch-all parameter \"rest\" in `/docs/{*rest}` was given no segment"
)]
fn rejects_an_empty_catch_all() {
let _ = assign("/docs/{*rest}", &(Tail("rest", &[]),));
}
#[test]
#[should_panic(expected = "parameter \"id\" in `/users/{id}` was given an empty segment")]
fn rejects_an_empty_segment() {
let _ = assign("/users/{id}", &(Param("id", ""),));
}
#[test]
#[should_panic(expected = "parameter \"id\" in `/users/{id}` was given \"..\"")]
fn rejects_a_parent_segment() {
let _ = assign("/users/{id}", &(Param("id", ".."),));
}
#[test]
#[should_panic(expected = "parameter \"rest\" in `/docs/{*rest}` was given \".\"")]
fn rejects_a_current_segment_inside_a_catch_all() {
let _ = assign("/docs/{*rest}", &(Tail("rest", &["guides", "."]),));
}
#[test]
fn keeps_a_segment_that_only_starts_with_a_dot() {
assert_eq!(
assign("/files/{name}", &(Param("name", ".gitignore"),)),
"/files/.gitignore"
);
}
#[test]
#[should_panic(expected = "declares fewer parameters than the href provides")]
fn rejects_more_parameters_than_the_path_declares() {
let _ = assign("/users/{id}", &(Param("id", "42"), Param("extra", "1")));
}
#[test]
#[should_panic(expected = "no value provided for path parameter \"id\"")]
fn rejects_an_unfilled_path_parameter() {
let _ = assign("/users/{id}", &());
}
#[test]
fn writes_no_query_without_items() {
assert_eq!(assign_queries(&()), "");
}
#[test]
fn concatenates_query_items() {
assert_eq!(
assign_queries(&([("tag", "rust")], [("page", "2"), ("sort", "asc")])),
"?tag=rust&page=2&sort=asc"
);
}
#[test]
fn skips_query_items_that_serialize_to_nothing() {
#[derive(serde::Serialize)]
struct Empty {}
assert_eq!(
assign_queries(&(Empty {}, [("page", "2")], Empty {})),
"?page=2"
);
}
#[test]
fn percent_encodes_query_values() {
assert_eq!(assign_queries(&([("tag", "a b&c")],)), "?tag=a+b%26c");
}
#[test]
fn resolves_a_relative_url_with_query_and_fragment() {
let url = href("/users/{id}", (Param("id", "42"),))
.query([("page", "2")])
.fragment("bio")
.resolve(&Cx::default());
assert_eq!(url, "/users/42?page=2#bio");
}
#[test]
fn the_macro_takes_the_parameters_as_a_list() {
let url = href!(
"/users/{id}/posts/{post_id}",
Param("id", "42"),
Param("post_id", "7")
)
.resolve(&Cx::default());
assert_eq!(url, "/users/42/posts/7");
}
#[test]
fn the_macro_without_parameters_resolves_a_static_path() {
assert_eq!(href!("/users").resolve(&Cx::default()), "/users");
}
#[test]
fn a_set_form_overrides_the_context_form() {
let cx = Cx::default().with(UrlForm::Absolute);
let url = href("/users", ()).relative().resolve(&cx);
assert_eq!(url, "/users");
}
#[test]
fn resolves_an_absolute_url_behind_the_base_url() {
let url = href("/users/{id}", (Param("id", "42"),))
.query([("page", "2")])
.fragment("bio")
.absolute()
.resolve(&cx_with_base_url());
assert_eq!(url, "https://example.com/users/42?page=2#bio");
}
#[test]
fn an_absolute_root_url_keeps_its_slash() {
assert_eq!(
href("/", ()).absolute().resolve(&cx_with_base_url()),
"https://example.com/"
);
}
#[test]
fn the_context_form_turns_a_url_absolute() {
let cx = cx_with_base_url().with(UrlForm::Absolute);
assert_eq!(href("/users", ()).resolve(&cx), "https://example.com/users");
}
#[test]
fn is_current_on_the_request_path() {
assert!(href("/users", ()).is_current(&cx_with_uri("/users")));
assert!(!href("/users", ()).is_current(&cx_with_uri("/users/42")));
}
#[test]
fn is_current_fills_in_the_parameters() {
let href = href("/users/{id}", (Param("id", "42"),));
assert!(href.is_current(&cx_with_uri("/users/42")));
assert!(!href.is_current(&cx_with_uri("/users/7")));
}
#[test]
fn is_current_compares_the_encoded_path() {
let href = href("/users/{id}", (Param("id", "a/b"),));
assert!(href.is_current(&cx_with_uri("/users/a%2Fb")));
assert!(!href.is_current(&cx_with_uri("/users/a/b")));
}
#[test]
fn the_root_href_is_current_on_the_root() {
assert!(href("/", ()).is_current(&cx_with_uri("/")));
}
#[test]
fn an_unspecified_query_is_left_out_of_the_comparison() {
assert!(href("/users", ()).is_current(&cx_with_uri("/users?page=2")));
}
#[test]
fn a_specified_query_must_match_the_request_query() {
let href = href("/users", ()).query([("page", "2")]);
assert!(href.is_current(&cx_with_uri("/users?page=2")));
assert!(!href.is_current(&cx_with_uri("/users")));
assert!(!href.is_current(&cx_with_uri("/users?page=3")));
assert!(!href.is_current(&cx_with_uri("/users?page=2&sort=asc")));
}
#[test]
fn the_query_comparison_ignores_order_and_encoding() {
let href = href("/users", ()).query([("page", "2"), ("sort", "a b")]);
assert!(href.is_current(&cx_with_uri("/users?sort=a%20b&page=2")));
}
#[test]
fn a_query_that_serializes_to_nothing_demands_a_bare_request() {
#[derive(serde::Serialize)]
struct Empty {}
let href = href("/users", ()).query(Empty {});
assert!(href.is_current(&cx_with_uri("/users")));
assert!(!href.is_current(&cx_with_uri("/users?page=2")));
}
#[test]
fn the_fragment_is_left_out_of_the_comparison() {
let href = href("/users", ()).fragment("bio");
assert!(href.is_current(&cx_with_uri("/users")));
}
#[test]
fn builder_appends_queries_and_replaces_the_fragment() {
let href = href("/users", ())
.query([("a", "1")])
.fragment("one")
.query([("b", "2")])
.fragment("two");
assert_eq!(assign_queries(&href.queries), "?a=1&b=2");
assert_eq!(href.fragment, Some("two"));
}
}