use miette::SourceSpan;
use winnow::{
Parser, Stateful,
combinator::eof,
error::{ContextError, ParseError},
};
use crate::arena::Arena;
type Input<'a> = Stateful<&'a str, &'a Arena>;
pub fn parse<'a>(arena: &'a Arena, input: &'a str) -> Result<ParsedPath<'a>, BadPath> {
let stateful = Input {
input,
state: arena,
};
(self::parser::path, eof)
.map(|((segments, query), _)| ParsedPath {
segments: arena.alloc_slice_copy(&segments),
query: arena.alloc_slice_copy(&query),
})
.parse(stateful)
.map_err(BadPath::from_parse_error)
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct ParsedPath<'a> {
pub segments: &'a [PathSegment<'a>],
pub query: &'a [PathQueryParameter<'a>],
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct PathQueryParameter<'a> {
pub name: &'a str,
pub value: &'a str,
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub struct PathSegment<'input>(&'input [PathFragment<'input>]);
impl<'input> PathSegment<'input> {
pub fn fragments(&self) -> &'input [PathFragment<'input>] {
self.0
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum PathFragment<'input> {
Literal(&'input str),
Param(&'input str),
}
mod parser {
use super::*;
use std::borrow::Cow;
use winnow::{
Parser,
combinator::{alt, delimited, opt, preceded, repeat},
token::take_while,
};
pub fn path<'a>(
input: &mut Input<'a>,
) -> winnow::Result<(Vec<PathSegment<'a>>, Vec<PathQueryParameter<'a>>)> {
let segments = template.parse_next(input)?;
let query = opt(preceded(
'?',
take_while(0.., is_query_char).map(|query: &str| {
form_urlencoded::parse(query.as_bytes())
.map(|(name, value)| PathQueryParameter {
name: match name {
Cow::Borrowed(name) => name,
Cow::Owned(name) => input.state.alloc_str(&name),
},
value: match value {
Cow::Borrowed(value) => value,
Cow::Owned(value) => input.state.alloc_str(&value),
},
})
.collect()
}),
))
.parse_next(input)?;
Ok((segments, query.unwrap_or_default()))
}
fn template<'a>(input: &mut Input<'a>) -> winnow::Result<Vec<PathSegment<'a>>> {
alt((
('/', segment, template)
.map(|(_, head, tail)| std::iter::once(head).chain(tail).collect()),
('/', segment).map(|(_, segment)| vec![segment]),
'/'.map(|_| vec![PathSegment::default()]),
))
.parse_next(input)
}
fn segment<'a>(input: &mut Input<'a>) -> winnow::Result<PathSegment<'a>> {
repeat(1.., fragment)
.map(|fragments: Vec<_>| PathSegment(input.state.alloc_slice_copy(&fragments)))
.parse_next(input)
}
fn fragment<'a>(input: &mut Input<'a>) -> winnow::Result<PathFragment<'a>> {
alt((param, literal)).parse_next(input)
}
pub fn param<'a>(input: &mut Input<'a>) -> winnow::Result<PathFragment<'a>> {
delimited('{', take_while(1.., |c| c != '{' && c != '}'), '}')
.map(PathFragment::Param)
.parse_next(input)
}
pub fn literal<'a>(input: &mut Input<'a>) -> winnow::Result<PathFragment<'a>> {
take_while(1.., is_path_char)
.verify_map(|text: &str| {
let decoded = percent_encoding::percent_decode_str(text)
.decode_utf8()
.ok()?;
Some(PathFragment::Literal(match decoded {
Cow::Borrowed(s) => s,
Cow::Owned(s) => input.state.alloc_str(&s),
}))
})
.parse_next(input)
}
fn is_path_char(c: char) -> bool {
is_query_char(c) && !matches!(c, '/' | '?' | '^' | '`' | '{' | '}')
}
fn is_query_char(c: char) -> bool {
!matches!(
c,
'\x00'..='\x1f' | ('\x7f'..) | ' ' | '"' | '#' | '<' | '>'
)
}
}
#[derive(Debug, miette::Diagnostic, thiserror::Error)]
#[error("invalid URL path template")]
pub struct BadPath {
#[source_code]
code: String,
#[label]
span: SourceSpan,
}
impl BadPath {
fn from_parse_error(error: ParseError<Input<'_>, ContextError>) -> Self {
let stateful = error.input();
Self {
code: stateful.input.to_owned(),
span: error.char_span().into(),
}
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::tests::assert_matches;
#[test]
fn test_root_path() {
let arena = Arena::new();
let result = parse(&arena, "/").unwrap();
assert_matches!(result.segments, [PathSegment([])]);
assert!(result.query.is_empty());
}
#[test]
fn test_simple_literal() {
let arena = Arena::new();
let result = parse(&arena, "/users").unwrap();
assert_matches!(
result.segments,
[PathSegment([PathFragment::Literal("users")])],
);
}
#[test]
fn test_trailing_slash() {
let arena = Arena::new();
let result = parse(&arena, "/users/").unwrap();
assert_matches!(
result.segments,
[
PathSegment([PathFragment::Literal("users")]),
PathSegment([]),
],
);
}
#[test]
fn test_simple_template() {
let arena = Arena::new();
let result = parse(&arena, "/users/{userId}").unwrap();
assert_matches!(
result.segments,
[
PathSegment([PathFragment::Literal("users")]),
PathSegment([PathFragment::Param("userId")]),
],
);
}
#[test]
fn test_nested_path() {
let arena = Arena::new();
let result = parse(&arena, "/api/v1/resources/{resourceId}").unwrap();
assert_matches!(
result.segments,
[
PathSegment([PathFragment::Literal("api")]),
PathSegment([PathFragment::Literal("v1")]),
PathSegment([PathFragment::Literal("resources")]),
PathSegment([PathFragment::Param("resourceId")]),
],
);
}
#[test]
fn test_multiple_templates() {
let arena = Arena::new();
let result = parse(&arena, "/users/{userId}/posts/{postId}").unwrap();
assert_matches!(
result.segments,
[
PathSegment([PathFragment::Literal("users")]),
PathSegment([PathFragment::Param("userId")]),
PathSegment([PathFragment::Literal("posts")]),
PathSegment([PathFragment::Param("postId")]),
],
);
}
#[test]
fn test_literal_with_extension() {
let arena = Arena::new();
let result = parse(
&arena,
"/v1/storage/workspace/{workspace}/documents/download/{documentId}.pdf",
)
.unwrap();
assert_matches!(
result.segments,
[
PathSegment([PathFragment::Literal("v1")]),
PathSegment([PathFragment::Literal("storage")]),
PathSegment([PathFragment::Literal("workspace")]),
PathSegment([PathFragment::Param("workspace")]),
PathSegment([PathFragment::Literal("documents")]),
PathSegment([PathFragment::Literal("download")]),
PathSegment([
PathFragment::Param("documentId"),
PathFragment::Literal(".pdf"),
]),
],
);
}
#[test]
fn test_mixed_literal_and_param() {
let arena = Arena::new();
let result = parse(
&arena,
"/v1/storage/workspace/{workspace}/documents/download/report-{documentId}.pdf",
)
.unwrap();
assert_matches!(
result.segments,
[
PathSegment([PathFragment::Literal("v1")]),
PathSegment([PathFragment::Literal("storage")]),
PathSegment([PathFragment::Literal("workspace")]),
PathSegment([PathFragment::Param("workspace")]),
PathSegment([PathFragment::Literal("documents")]),
PathSegment([PathFragment::Literal("download")]),
PathSegment([
PathFragment::Literal("report-"),
PathFragment::Param("documentId"),
PathFragment::Literal(".pdf"),
]),
],
);
}
#[test]
fn test_double_slash() {
let arena = Arena::new();
assert!(parse(&arena, "/users//a").is_err());
}
#[test]
fn test_invalid_chars_in_template() {
let arena = Arena::new();
assert!(parse(&arena, "/users/{user/{id}}").is_err());
}
#[test]
fn test_path_with_single_query_param() {
let arena = Arena::new();
let result = parse(&arena, "/v1/messages?beta=true").unwrap();
assert_matches!(
result,
ParsedPath {
segments: [
PathSegment([PathFragment::Literal("v1")]),
PathSegment([PathFragment::Literal("messages")]),
],
query: [PathQueryParameter {
name: "beta",
value: "true",
}],
},
);
}
#[test]
fn test_path_with_multiple_query_params() {
let arena = Arena::new();
let result = parse(&arena, "/v1/items?beta=true&version=2").unwrap();
assert_matches!(
result,
ParsedPath {
segments: [
PathSegment([PathFragment::Literal("v1")]),
PathSegment([PathFragment::Literal("items")]),
],
query: [
PathQueryParameter {
name: "beta",
value: "true",
},
PathQueryParameter {
name: "version",
value: "2",
},
],
},
);
}
#[test]
fn test_path_with_template_and_query_param() {
let arena = Arena::new();
let result = parse(&arena, "/v1/models/{model_id}?beta=true").unwrap();
assert_matches!(
result,
ParsedPath {
segments: [
PathSegment([PathFragment::Literal("v1")]),
PathSegment([PathFragment::Literal("models")]),
PathSegment([PathFragment::Param("model_id")]),
],
query: [PathQueryParameter {
name: "beta",
value: "true",
}],
},
);
}
#[test]
fn test_path_with_valueless_query_param() {
let arena = Arena::new();
let result = parse(&arena, "/v1/items?beta").unwrap();
assert_matches!(
result,
ParsedPath {
segments: [
PathSegment([PathFragment::Literal("v1")]),
PathSegment([PathFragment::Literal("items")]),
],
query: [PathQueryParameter {
name: "beta",
value: "",
}],
},
);
}
#[test]
fn test_path_with_trailing_question_mark() {
let arena = Arena::new();
let result = parse(&arena, "/foo?").unwrap();
assert_matches!(
result,
ParsedPath {
segments: [PathSegment([PathFragment::Literal("foo")])],
query: [],
},
);
}
#[test]
fn test_path_with_percent_encoded_query_params() {
let arena = Arena::new();
let result = parse(&arena, "/foo?a%20b=c%20d").unwrap();
assert_matches!(
result,
ParsedPath {
segments: [PathSegment([PathFragment::Literal("foo")])],
query: [PathQueryParameter {
name: "a b",
value: "c d",
}],
},
);
}
#[test]
fn test_root_path_with_query_param() {
let arena = Arena::new();
let result = parse(&arena, "/?beta=true").unwrap();
assert_matches!(
result,
ParsedPath {
segments: [PathSegment([])],
query: [PathQueryParameter {
name: "beta",
value: "true",
}],
},
);
}
}