use std::{
pin::Pin,
task::{Context, Poll},
time::Duration,
};
use bytes::Bytes;
use http_body::Frame;
use url::Url;
use crate::error::BuildRequestError;
pub trait IntoUrl: sealed::Sealed {
fn into_url(self) -> Result<Url, BuildRequestError>;
}
impl IntoUrl for Url {
fn into_url(self) -> Result<Url, BuildRequestError> {
Ok(self)
}
}
impl IntoUrl for &str {
fn into_url(self) -> Result<Url, BuildRequestError> {
Url::parse(self).map_err(|e| {
BuildRequestError::InvalidUrl {
source: Box::new(e),
}
})
}
}
impl IntoUrl for String {
fn into_url(self) -> Result<Url, BuildRequestError> {
self.as_str().into_url()
}
}
impl IntoUrl for &String {
fn into_url(self) -> Result<Url, BuildRequestError> {
self.as_str().into_url()
}
}
mod sealed {
pub trait Sealed {}
impl Sealed for url::Url {}
impl Sealed for &str {}
impl Sealed for String {}
impl Sealed for &String {}
}
#[derive(Debug, Clone)]
pub struct Request {
method: http::Method,
url: Url,
headers: http::HeaderMap,
body: Bytes,
timeout: Option<Duration>,
}
impl Request {
#[must_use]
pub fn builder() -> RequestBuilder {
RequestBuilder::new()
}
#[must_use]
pub fn get(url: impl IntoUrl) -> RequestBuilder {
RequestBuilder::new().method(http::Method::GET).url(url)
}
#[must_use]
pub fn post(url: impl IntoUrl) -> RequestBuilder {
RequestBuilder::new().method(http::Method::POST).url(url)
}
#[must_use]
pub fn method(&self) -> &http::Method {
&self.method
}
#[must_use]
pub fn url(&self) -> &Url {
&self.url
}
#[must_use]
pub fn headers(&self) -> &http::HeaderMap {
&self.headers
}
#[must_use]
pub fn body(&self) -> &Bytes {
&self.body
}
#[must_use]
pub fn request_timeout(&self) -> Option<Duration> {
self.timeout
}
pub(crate) fn to_http(&self) -> Result<http::Request<BufferedBody>, crate::Error> {
let mut builder = http::Request::builder()
.method(self.method.clone())
.uri(self.url.as_str());
if let Some(headers) = builder.headers_mut() {
headers.extend(self.headers.clone());
}
builder
.body(BufferedBody::new(self.body.clone()))
.map_err(|e| {
crate::Error::InvalidRequest {
reason: e.to_string().into(),
}
})
}
}
#[derive(Debug)]
pub struct RequestBuilder {
method: http::Method,
url: Option<Url>,
headers: http::HeaderMap,
body: Bytes,
timeout: Option<Duration>,
error: Option<BuildRequestError>,
}
impl RequestBuilder {
fn new() -> Self {
RequestBuilder {
method: http::Method::GET,
url: None,
headers: http::HeaderMap::new(),
body: Bytes::new(),
timeout: None,
error: None,
}
}
#[must_use]
pub fn method(mut self, method: http::Method) -> Self {
self.method = method;
self
}
#[must_use]
pub fn url(mut self, url: impl IntoUrl) -> Self {
match url.into_url() {
Ok(url) => self.url = Some(url),
Err(e) => {
self.error.get_or_insert(e);
}
}
self
}
#[must_use]
pub fn header<K, V>(mut self, key: K, value: V) -> Self
where
K: TryInto<http::header::HeaderName>,
K::Error: Into<http::Error>,
V: TryInto<http::header::HeaderValue>,
V::Error: Into<http::Error>,
{
match (key.try_into(), value.try_into()) {
(Ok(key), Ok(value)) => {
self.headers.append(key, value);
}
(Err(e), _) => {
self.error.get_or_insert(BuildRequestError::InvalidHeader {
source: Box::new(e.into()),
});
}
(_, Err(e)) => {
self.error.get_or_insert(BuildRequestError::InvalidHeader {
source: Box::new(e.into()),
});
}
}
self
}
#[must_use]
pub fn body(mut self, body: impl Into<Bytes>) -> Self {
self.body = body.into();
self
}
#[must_use]
pub fn request_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn build(self) -> Result<Request, BuildRequestError> {
if let Some(error) = self.error {
return Err(error);
}
let url = self.url.ok_or(BuildRequestError::MissingUrl)?;
if url.scheme() != "https" {
return Err(BuildRequestError::UnsupportedScheme {
scheme: url.scheme().to_string(),
});
}
if url.host_str().is_none() {
return Err(BuildRequestError::MissingHost);
}
if !url.username().is_empty() || url.password().is_some() {
return Err(BuildRequestError::UserinfoNotAllowed);
}
Ok(Request {
method: self.method,
url,
headers: self.headers,
body: self.body,
timeout: self.timeout,
})
}
}
pub(crate) struct BufferedBody {
data: Option<Bytes>,
}
impl BufferedBody {
fn new(data: Bytes) -> Self {
let data = if data.is_empty() { None } else { Some(data) };
BufferedBody { data }
}
}
impl http_body::Body for BufferedBody {
type Data = Bytes;
type Error = std::convert::Infallible;
fn poll_frame(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
Poll::Ready(self.get_mut().data.take().map(|data| Ok(Frame::data(data))))
}
fn is_end_stream(&self) -> bool {
self.data.is_none()
}
fn size_hint(&self) -> http_body::SizeHint {
let len = self.data.as_ref().map_or(0, Bytes::len) as u64;
http_body::SizeHint::with_exact(len)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn get_builds_with_defaults() {
let req = Request::get("https://chat.example.org/rooms")
.build()
.unwrap();
assert_eq!(req.method(), http::Method::GET);
assert_eq!(req.url().as_str(), "https://chat.example.org/rooms");
assert!(req.request_timeout().is_none());
assert!(req.body().is_empty());
}
#[test]
fn non_https_scheme_is_rejected() {
let err = Request::get("http://chat.example.org/")
.build()
.unwrap_err();
assert!(matches!(
err,
BuildRequestError::UnsupportedScheme { scheme } if scheme == "http"
));
}
#[test]
fn userinfo_is_rejected() {
for url in [
"https://user:secret@api.example.org/x",
"https://user@api.example.org/x",
] {
let err = Request::get(url).build().unwrap_err();
assert!(
matches!(err, BuildRequestError::UserinfoNotAllowed),
"{url}"
);
}
}
#[test]
fn unparsable_url_is_reported_at_build() {
let err = Request::get("not a url").build().unwrap_err();
assert!(matches!(err, BuildRequestError::InvalidUrl { .. }));
}
#[test]
fn missing_url_is_reported_at_build() {
let err = Request::builder().build().unwrap_err();
assert!(matches!(err, BuildRequestError::MissingUrl));
}
#[test]
fn invalid_header_is_deferred_to_build() {
let err = Request::get("https://example.org/")
.header("bad header name", "value")
.build()
.unwrap_err();
assert!(matches!(err, BuildRequestError::InvalidHeader { .. }));
}
#[test]
fn to_http_uses_the_full_url_as_uri() {
let req = Request::post("https://example.org:8443/echo")
.header("content-type", "text/plain")
.body("hello")
.build()
.unwrap();
let http_req = req.to_http().unwrap();
assert_eq!(http_req.uri(), "https://example.org:8443/echo");
assert_eq!(
http_req.uri().authority().map(http::uri::Authority::as_str),
Some("example.org:8443")
);
assert_eq!(http_req.headers()["content-type"], "text/plain");
}
}