fix(util): Align Client::request() path parsing with core.

`http::uri::PathAndQuery` requires an absolute path, and is more
lenient than the WHATWG URL Standard that `ploidy_core::parse::path`
follows. Replace `PathAndQuery` with a custom parser that accepts
both relative and absolute paths, and follows the same URL grammar as
the Ploidy compiler.
This commit is contained in:
Lina Butler
2026-06-15 16:56:44 -07:00
parent 73ad1487f1
commit 91414066b9
4 changed files with 179 additions and 32 deletions
+17 -22
View File
@@ -124,39 +124,34 @@ impl ToTokens for CodegenClientModule<'_> {
/// Returns a raw [`RequestBuilder`].
///
/// Constructs the request URL by appending `path_and_query`
/// to the base URL's path and query, respectively. For example,
/// given a base URL of `https://api.example.com/v1` and a
/// `path_and_query` of `/pets/list?limit=10`, the request URL is
/// to the base URL's path and query. The path can be relative or
/// absolute; its segments are appended to the base path.
/// Appended query parameters are not deduplicated.
///
/// For example, if this client's base URL is
/// `https://api.example.com/v1` and `path_and_query` is
/// `/pets/list?limit=10`, the request URL is
/// `https://api.example.com/v1/pets/list?limit=10`.
/// Prefer using the builder's [`query`] method to append
/// dynamic query parameters; use `path_and_query` for static
/// parameters.
///
/// The request includes the client's default headers.
///
/// Use this for requests that the typed client methods
/// don't support.
/// Use this for requests that the client's operation methods
/// don't cover.
///
/// [`RequestBuilder`]: crate::util::reqwest::RequestBuilder
/// [`query`]: crate::util::reqwest::RequestBuilder::query
pub fn request(
&self,
method: crate::util::reqwest::Method,
path_and_query: &str,
) -> Result<crate::util::reqwest::RequestBuilder, crate::error::Error> {
let parts: ::ploidy_util::http::uri::PathAndQuery = path_and_query.parse()?;
let mut url = self.base_url.clone();
let _ = url.path_segments_mut().map(|mut segments| {
let path = parts.path();
if path != "/" {
let path = path
.strip_prefix('/') // Drop leading `/` from new path.
.unwrap_or(path);
segments
.pop_if_empty() // Drop trailing `/` from the base path.
.extend(path.split('/'));
}
});
if let Some(query) = parts.query() {
url.query_pairs_mut()
.extend_pairs(::ploidy_util::url::form_urlencoded::parse(query.as_bytes()));
}
let url = ::ploidy_util::url::UrlExt::with_path_and_query(
self.base_url.clone(),
path_and_query,
)?;
Ok(self.client
.request(method, url)
.headers(self.headers.clone()))
+9 -9
View File
@@ -1,9 +1,9 @@
use std::fmt::{Display, Formatter, Result as FmtResult};
use http::{HeaderName, StatusCode, uri::InvalidUri};
use http::{HeaderName, StatusCode};
use url::ParseError as UrlParseError;
use crate::query::QueryParamError;
use crate::{query::QueryParamError, url::PathAndQueryError};
/// A client error.
#[derive(Debug, thiserror::Error)]
@@ -47,18 +47,18 @@ impl Error {
}
}
impl From<InvalidUri> for Error {
fn from(err: InvalidUri) -> Self {
Self::Build(BuildError::Path(err))
}
}
impl From<QueryParamError> for Error {
fn from(err: QueryParamError) -> Self {
Self::Build(BuildError::QueryParam(err))
}
}
impl From<PathAndQueryError> for Error {
fn from(err: PathAndQueryError) -> Self {
Self::Build(BuildError::Path(err))
}
}
impl From<UrlParseError> for Error {
fn from(err: UrlParseError) -> Self {
Self::Build(BuildError::Url(err))
@@ -99,7 +99,7 @@ pub enum BuildError {
#[error("invalid query parameter")]
QueryParam(#[source] QueryParamError),
#[error("invalid request path")]
Path(#[source] InvalidUri),
Path(#[source] PathAndQueryError),
#[error("invalid header name")]
HeaderName(#[source] http::Error),
#[error("invalid value for header `{0}`")]
+1 -1
View File
@@ -5,6 +5,7 @@ pub mod error;
pub mod query;
#[cfg(feature = "trace-context")]
pub mod trace;
pub mod url;
pub use absent::{AbsentError, AbsentOr, AbsentOrExt, FieldAbsentError};
pub use binary::{Base64, Base64Error};
@@ -24,5 +25,4 @@ pub use serde_json;
pub use serde_path_to_error;
#[cfg(feature = "tracing")]
pub use tracing;
pub use url;
pub use uuid;
+152
View File
@@ -0,0 +1,152 @@
use percent_encoding::percent_decode_str;
pub use ::url::*;
/// Extensions to [`Url`].
pub trait UrlExt: Sized {
/// Returns this URL with path segments and query parameters from
/// `path_and_query` appended.
fn with_path_and_query(self, path_and_query: &str) -> Result<Self, PathAndQueryError>;
}
impl UrlExt for Url {
fn with_path_and_query(mut self, path_and_query: &str) -> Result<Self, PathAndQueryError> {
let path_and_query = path_and_query.strip_prefix('/').unwrap_or(path_and_query);
let (path, query) = path_and_query
.split_once('?')
.unwrap_or((path_and_query, ""));
if !path.is_empty() {
let mut segments = self.path_segments_mut().map_err(|()| PathAndQueryError)?;
segments.pop_if_empty();
for segment in path.split('/') {
if segment.is_empty() || !segment.chars().all(is_path_char) {
Err(PathAndQueryError)?;
}
segments.push(
&percent_decode_str(segment)
.decode_utf8()
.map_err(|_| PathAndQueryError)?,
);
}
}
if !query.is_empty() {
if !query.chars().all(is_query_char) {
Err(PathAndQueryError)?;
}
self.query_pairs_mut()
.extend_pairs(::url::form_urlencoded::parse(query.as_bytes()));
}
Ok(self)
}
}
/// An error returned when a path and query can't be parsed.
#[derive(Clone, Copy, Debug, thiserror::Error)]
#[error("invalid path and query")]
pub struct PathAndQueryError;
/// Returns whether `c` is allowed in a URL path segment per
/// the WHATWG URL Standard's [path percent-encode set][set].
///
/// Matches `ploidy_core::parse::path`; duplicated here to avoid
/// `ploidy-util` depending on `ploidy-core`.
///
/// [set]: https://url.spec.whatwg.org/#path-percent-encode-set
fn is_path_char(c: char) -> bool {
is_query_char(c) && !matches!(c, '/' | '?' | '^' | '`' | '{' | '}')
}
/// Returns whether `c` is allowed in a URL query string per
/// the WHATWG URL Standard's [query percent-encode set][set].
/// Duplicated from `ploidy_core::parse::path`.
///
/// [set]: https://url.spec.whatwg.org/#query-percent-encode-set
fn is_query_char(c: char) -> bool {
!matches!(
c,
'\x00'..='\x1f' | ('\x7f'..) | ' ' | '"' | '#' | '<' | '>'
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_appends_relative_path_and_query() {
let url = Url::parse("https://api.example.com/v1")
.unwrap()
.with_path_and_query("pets/list?limit=10")
.unwrap();
assert_eq!(
url.as_str(),
"https://api.example.com/v1/pets/list?limit=10"
);
}
#[test]
fn test_appends_absolute_path() {
let url = Url::parse("https://api.example.com/v1/")
.unwrap()
.with_path_and_query("/pets/list")
.unwrap();
assert_eq!(url.as_str(), "https://api.example.com/v1/pets/list");
}
#[test]
fn test_appends_query_only() {
let url = Url::parse("https://api.example.com/v1?beta=true")
.unwrap()
.with_path_and_query("?limit=10")
.unwrap();
assert_eq!(
url.as_str(),
"https://api.example.com/v1?beta=true&limit=10"
);
}
#[test]
fn test_decodes_path_segments_before_appending() {
let url = Url::parse("https://api.example.com/v1")
.unwrap()
.with_path_and_query("pets/%E6%9F%B4%20%E7%8A%AC")
.unwrap();
assert_eq!(
url.as_str(),
"https://api.example.com/v1/pets/%E6%9F%B4%20%E7%8A%AC"
);
}
#[test]
fn test_ignores_empty_query() {
let url = Url::parse("https://api.example.com/v1")
.unwrap()
.with_path_and_query("?")
.unwrap();
assert_eq!(url.as_str(), "https://api.example.com/v1");
}
#[test]
fn test_rejects_invalid_path_char() {
let url = Url::parse("https://api.example.com/v1").unwrap();
let err = url.with_path_and_query("pets/{id}");
assert!(err.is_err());
}
#[test]
fn test_rejects_empty_path_segment() {
let url = Url::parse("https://api.example.com/v1").unwrap();
let err = url.with_path_and_query("pets//list");
assert!(err.is_err());
}
#[test]
fn test_rejects_invalid_query_char() {
let url = Url::parse("https://api.example.com/v1").unwrap();
let err = url.with_path_and_query("pets?tag=dog#cat");
assert!(err.is_err());
}
}