From b674c1efaf2cca5c8f77a5671d8323c3051d9889 Mon Sep 17 00:00:00 2001 From: Calvin Prewitt Date: Mon, 17 Feb 2025 11:07:37 -0800 Subject: [PATCH] adding `.json()` method for better ergonomics similar to `reqwest` crate --- Cargo.toml | 8 ++++++++ src/http/body.rs | 43 +++++++++++++++++++++++++++++++++++++--- src/http/client.rs | 6 ++++++ src/http/error.rs | 2 +- src/http/request.rs | 44 +++++++++++++++++++++++++++++++++++++++++ src/runtime/mod.rs | 2 +- tests/http_get_json.rs | 30 ++++++++++++++++++++++++++++ tests/http_post_json.rs | 43 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 tests/http_get_json.rs create mode 100644 tests/http_post_json.rs diff --git a/Cargo.toml b/Cargo.toml index 942d88a..2628f45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,8 @@ categories.workspace = true repository.workspace = true [features] +default = ["json"] +json = ["dep:serde", "dep:serde_json"] [dependencies] futures-core.workspace = true @@ -22,11 +24,16 @@ slab.workspace = true wasi.workspace = true wstd-macro.workspace = true +# optional +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } + [dev-dependencies] anyhow.workspace = true clap.workspace = true futures-lite.workspace = true humantime.workspace = true +serde = { workspace = true, features = ["derive"] } serde_json.workspace = true [workspace] @@ -62,6 +69,7 @@ http = "1.1" itoa = "1" pin-project-lite = "0.2.8" quote = "1.0" +serde= "1" serde_json = "1" slab = "0.4.9" syn = "2.0" diff --git a/src/http/body.rs b/src/http/body.rs index 318d6a6..3671c22 100644 --- a/src/http/body.rs +++ b/src/http/body.rs @@ -4,9 +4,14 @@ use crate::http::fields::header_map_from_wasi; use crate::io::{AsyncInputStream, AsyncOutputStream, AsyncRead, AsyncWrite, Cursor, Empty}; use crate::runtime::AsyncPollable; use core::fmt; -use http::header::{CONTENT_LENGTH, TRANSFER_ENCODING}; +use http::header::CONTENT_LENGTH; use wasi::http::types::IncomingBody as WasiIncomingBody; +#[cfg(feature = "json")] +use serde::de::DeserializeOwned; +#[cfg(feature = "json")] +use serde_json; + pub use super::{ error::{Error, ErrorVariant}, HeaderMap, @@ -26,8 +31,6 @@ impl BodyKind { .parse::() .map_err(|_| InvalidContentLength)?; Ok(BodyKind::Fixed(content_length)) - } else if headers.contains_key(TRANSFER_ENCODING) { - Ok(BodyKind::Chunked) } else { Ok(BodyKind::Chunked) } @@ -176,6 +179,40 @@ impl IncomingBody { Ok(trailers) } + + /// Try to deserialize the incoming body as JSON. The optional + /// `json` feature is required. + /// + /// Fails whenever the response body is not in JSON format, + /// or it cannot be properly deserialized to target type `T`. For more + /// details please see [`serde_json::from_reader`]. + /// + /// [`serde_json::from_reader`]: https://docs.serde.rs/serde_json/fn.from_reader.html + #[cfg(feature = "json")] + #[cfg_attr(docsrs, doc(cfg(feature = "json")))] + pub async fn json(&mut self) -> Result { + let buf = self.bytes().await?; + serde_json::from_slice(&buf).map_err(|e| ErrorVariant::Other(e.to_string()).into()) + } + + /// Get the full response body as `Vec`. + pub async fn bytes(&mut self) -> Result, Error> { + let mut buf = match self.kind { + BodyKind::Fixed(l) => { + if l > (usize::MAX as u64) { + return Err(ErrorVariant::Other( + "incoming body is too large to allocate and buffer in memory".to_string(), + ) + .into()); + } else { + Vec::with_capacity(l as usize) + } + } + BodyKind::Chunked => Vec::with_capacity(4096), + }; + self.read_to_end(&mut buf).await?; + Ok(buf) + } } impl AsyncRead for IncomingBody { diff --git a/src/http/client.rs b/src/http/client.rs index 3975f2e..ad7f10c 100644 --- a/src/http/client.rs +++ b/src/http/client.rs @@ -24,6 +24,12 @@ pub struct Client { options: Option, } +impl Default for Client { + fn default() -> Self { + Self::new() + } +} + impl Client { /// Create a new instance of `Client` pub fn new() -> Self { diff --git a/src/http/error.rs b/src/http/error.rs index bfa5c36..34a70ae 100644 --- a/src/http/error.rs +++ b/src/http/error.rs @@ -17,7 +17,7 @@ pub use wasi::http::types::{ErrorCode as WasiHttpErrorCode, HeaderError as WasiH impl fmt::Debug for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { for c in self.context.iter() { - write!(f, "in {c}:\n")?; + writeln!(f, "in {c}:")?; } match &self.variant { ErrorVariant::WasiHttp(e) => write!(f, "wasi http error: {e:?}"), diff --git a/src/http/request.rs b/src/http/request.rs index 4b1e21f..f5e38ec 100644 --- a/src/http/request.rs +++ b/src/http/request.rs @@ -12,6 +12,50 @@ use wasi::http::types::IncomingRequest; pub use http::request::{Builder, Request}; +#[cfg(feature = "json")] +use super::{ + body::{BoundedBody, IntoBody}, + error::ErrorVariant, +}; +#[cfg(feature = "json")] +use http::header::{HeaderValue, CONTENT_TYPE}; +#[cfg(feature = "json")] +use serde::Serialize; +#[cfg(feature = "json")] +use serde_json; + +#[cfg(feature = "json")] +pub trait JsonRequest { + fn json(self, json: &T) -> Result>>, Error>; +} + +#[cfg(feature = "json")] +impl JsonRequest for Builder { + /// Send a JSON body. Requires optional `json` feature. + /// + /// Serialization can fail if `T`'s implementation of `Serialize` decides to + /// fail. + #[cfg(feature = "json")] + #[cfg_attr(docsrs, doc(cfg(feature = "json")))] + fn json(self, json: &T) -> Result>>, Error> { + let encoded = serde_json::to_vec(json).map_err(|e| ErrorVariant::Other(e.to_string()))?; + let builder = if !self + .headers_ref() + .is_some_and(|headers| headers.contains_key(CONTENT_TYPE)) + { + self.header( + CONTENT_TYPE, + HeaderValue::from_static("application/json; charset=utf-8"), + ) + } else { + self + }; + builder + .body(encoded.into_body()) + .map_err(|e| ErrorVariant::Other(e.to_string()).into()) + } +} + pub(crate) fn try_into_outgoing(request: Request) -> Result<(OutgoingRequest, T), Error> { let wasi_req = OutgoingRequest::new(header_map_to_wasi(request.headers())?); diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index fe449bc..6b01a1f 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -20,5 +20,5 @@ use std::cell::RefCell; // There are no threads in WASI 0.2, so this is just a safe way to thread a single reactor to all // use sites in the background. std::thread_local! { -pub(crate) static REACTOR: RefCell> = RefCell::new(None); +pub(crate) static REACTOR: RefCell> = const { RefCell::new(None) }; } diff --git a/tests/http_get_json.rs b/tests/http_get_json.rs new file mode 100644 index 0000000..de409d3 --- /dev/null +++ b/tests/http_get_json.rs @@ -0,0 +1,30 @@ +use serde::Deserialize; +use std::error::Error; +use wstd::http::{Client, Request}; +use wstd::io::empty; + +#[derive(Deserialize)] +struct Echo { + url: String, +} + +#[wstd::test] +async fn main() -> Result<(), Box> { + let request = Request::get("https://postman-echo.com/get").body(empty())?; + + let mut response = Client::new().send(request).await?; + + let content_type = response + .headers() + .get("Content-Type") + .ok_or_else(|| "response expected to have Content-Type header")?; + assert_eq!(content_type, "application/json; charset=utf-8"); + + let Echo { url } = response.body_mut().json::().await?; + assert!( + url.contains("postman-echo.com/get"), + "expected body url to contain the authority and path, got: {url}" + ); + + Ok(()) +} diff --git a/tests/http_post_json.rs b/tests/http_post_json.rs new file mode 100644 index 0000000..5ccb0cc --- /dev/null +++ b/tests/http_post_json.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; +use std::error::Error; +use wstd::http::{request::JsonRequest, Client, Request}; + +#[derive(Serialize)] +struct TestData { + test: String, +} + +#[derive(Deserialize)] +struct Echo { + url: String, +} + +#[wstd::test] +async fn main() -> Result<(), Box> { + let test_data = TestData { + test: "data".to_string(), + }; + let request = Request::post("https://postman-echo.com/post").json(&test_data)?; + + let content_type = request + .headers() + .get("Content-Type") + .ok_or_else(|| "request expected to have Content-Type header")?; + assert_eq!(content_type, "application/json; charset=utf-8"); + + let mut response = Client::new().send(request).await?; + + let content_type = response + .headers() + .get("Content-Type") + .ok_or_else(|| "response expected to have Content-Type header")?; + assert_eq!(content_type, "application/json; charset=utf-8"); + + let Echo { url } = response.body_mut().json::().await?; + assert!( + url.contains("postman-echo.com/post"), + "expected body url to contain the authority and path, got: {url}" + ); + + Ok(()) +}