mirror of
https://github.com/linabutler/ploidy
synced 2026-08-12 01:43:47 +00:00
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:
@@ -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()))
|
||||
|
||||
@@ -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}`")]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user