Skip to content

Commit 5f3a273

Browse files
authored
Merge pull request #63 from calvinrp/opt-feat-serde-json
adding `.json()` method for better ergonomics similar to `reqwest` crate
2 parents ecae9a5 + b674c1e commit 5f3a273

File tree

8 files changed

+173
-5
lines changed

8 files changed

+173
-5
lines changed

Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ categories.workspace = true
1212
repository.workspace = true
1313

1414
[features]
15+
default = ["json"]
16+
json = ["dep:serde", "dep:serde_json"]
1517

1618
[dependencies]
1719
futures-core.workspace = true
@@ -22,11 +24,16 @@ slab.workspace = true
2224
wasi.workspace = true
2325
wstd-macro.workspace = true
2426

27+
# optional
28+
serde = { workspace = true, optional = true }
29+
serde_json = { workspace = true, optional = true }
30+
2531
[dev-dependencies]
2632
anyhow.workspace = true
2733
clap.workspace = true
2834
futures-lite.workspace = true
2935
humantime.workspace = true
36+
serde = { workspace = true, features = ["derive"] }
3037
serde_json.workspace = true
3138

3239
[workspace]
@@ -62,6 +69,7 @@ http = "1.1"
6269
itoa = "1"
6370
pin-project-lite = "0.2.8"
6471
quote = "1.0"
72+
serde= "1"
6573
serde_json = "1"
6674
slab = "0.4.9"
6775
syn = "2.0"

src/http/body.rs

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@ use crate::http::fields::header_map_from_wasi;
44
use crate::io::{AsyncInputStream, AsyncOutputStream, AsyncRead, AsyncWrite, Cursor, Empty};
55
use crate::runtime::AsyncPollable;
66
use core::fmt;
7-
use http::header::{CONTENT_LENGTH, TRANSFER_ENCODING};
7+
use http::header::CONTENT_LENGTH;
88
use wasi::http::types::IncomingBody as WasiIncomingBody;
99

10+
#[cfg(feature = "json")]
11+
use serde::de::DeserializeOwned;
12+
#[cfg(feature = "json")]
13+
use serde_json;
14+
1015
pub use super::{
1116
error::{Error, ErrorVariant},
1217
HeaderMap,
@@ -26,8 +31,6 @@ impl BodyKind {
2631
.parse::<u64>()
2732
.map_err(|_| InvalidContentLength)?;
2833
Ok(BodyKind::Fixed(content_length))
29-
} else if headers.contains_key(TRANSFER_ENCODING) {
30-
Ok(BodyKind::Chunked)
3134
} else {
3235
Ok(BodyKind::Chunked)
3336
}
@@ -176,6 +179,40 @@ impl IncomingBody {
176179

177180
Ok(trailers)
178181
}
182+
183+
/// Try to deserialize the incoming body as JSON. The optional
184+
/// `json` feature is required.
185+
///
186+
/// Fails whenever the response body is not in JSON format,
187+
/// or it cannot be properly deserialized to target type `T`. For more
188+
/// details please see [`serde_json::from_reader`].
189+
///
190+
/// [`serde_json::from_reader`]: https://docs.serde.rs/serde_json/fn.from_reader.html
191+
#[cfg(feature = "json")]
192+
#[cfg_attr(docsrs, doc(cfg(feature = "json")))]
193+
pub async fn json<T: DeserializeOwned>(&mut self) -> Result<T, Error> {
194+
let buf = self.bytes().await?;
195+
serde_json::from_slice(&buf).map_err(|e| ErrorVariant::Other(e.to_string()).into())
196+
}
197+
198+
/// Get the full response body as `Vec<u8>`.
199+
pub async fn bytes(&mut self) -> Result<Vec<u8>, Error> {
200+
let mut buf = match self.kind {
201+
BodyKind::Fixed(l) => {
202+
if l > (usize::MAX as u64) {
203+
return Err(ErrorVariant::Other(
204+
"incoming body is too large to allocate and buffer in memory".to_string(),
205+
)
206+
.into());
207+
} else {
208+
Vec::with_capacity(l as usize)
209+
}
210+
}
211+
BodyKind::Chunked => Vec::with_capacity(4096),
212+
};
213+
self.read_to_end(&mut buf).await?;
214+
Ok(buf)
215+
}
179216
}
180217

181218
impl AsyncRead for IncomingBody {

src/http/client.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ pub struct Client {
2424
options: Option<RequestOptions>,
2525
}
2626

27+
impl Default for Client {
28+
fn default() -> Self {
29+
Self::new()
30+
}
31+
}
32+
2733
impl Client {
2834
/// Create a new instance of `Client`
2935
pub fn new() -> Self {

src/http/error.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ pub use wasi::http::types::{ErrorCode as WasiHttpErrorCode, HeaderError as WasiH
1717
impl fmt::Debug for Error {
1818
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1919
for c in self.context.iter() {
20-
write!(f, "in {c}:\n")?;
20+
writeln!(f, "in {c}:")?;
2121
}
2222
match &self.variant {
2323
ErrorVariant::WasiHttp(e) => write!(f, "wasi http error: {e:?}"),

src/http/request.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,50 @@ use wasi::http::types::IncomingRequest;
1212

1313
pub use http::request::{Builder, Request};
1414

15+
#[cfg(feature = "json")]
16+
use super::{
17+
body::{BoundedBody, IntoBody},
18+
error::ErrorVariant,
19+
};
20+
#[cfg(feature = "json")]
21+
use http::header::{HeaderValue, CONTENT_TYPE};
22+
#[cfg(feature = "json")]
23+
use serde::Serialize;
24+
#[cfg(feature = "json")]
25+
use serde_json;
26+
27+
#[cfg(feature = "json")]
28+
pub trait JsonRequest {
29+
fn json<T: Serialize + ?Sized>(self, json: &T) -> Result<Request<BoundedBody<Vec<u8>>>, Error>;
30+
}
31+
32+
#[cfg(feature = "json")]
33+
impl JsonRequest for Builder {
34+
/// Send a JSON body. Requires optional `json` feature.
35+
///
36+
/// Serialization can fail if `T`'s implementation of `Serialize` decides to
37+
/// fail.
38+
#[cfg(feature = "json")]
39+
#[cfg_attr(docsrs, doc(cfg(feature = "json")))]
40+
fn json<T: Serialize + ?Sized>(self, json: &T) -> Result<Request<BoundedBody<Vec<u8>>>, Error> {
41+
let encoded = serde_json::to_vec(json).map_err(|e| ErrorVariant::Other(e.to_string()))?;
42+
let builder = if !self
43+
.headers_ref()
44+
.is_some_and(|headers| headers.contains_key(CONTENT_TYPE))
45+
{
46+
self.header(
47+
CONTENT_TYPE,
48+
HeaderValue::from_static("application/json; charset=utf-8"),
49+
)
50+
} else {
51+
self
52+
};
53+
builder
54+
.body(encoded.into_body())
55+
.map_err(|e| ErrorVariant::Other(e.to_string()).into())
56+
}
57+
}
58+
1559
pub(crate) fn try_into_outgoing<T>(request: Request<T>) -> Result<(OutgoingRequest, T), Error> {
1660
let wasi_req = OutgoingRequest::new(header_map_to_wasi(request.headers())?);
1761

src/runtime/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,5 @@ use std::cell::RefCell;
2020
// There are no threads in WASI 0.2, so this is just a safe way to thread a single reactor to all
2121
// use sites in the background.
2222
std::thread_local! {
23-
pub(crate) static REACTOR: RefCell<Option<Reactor>> = RefCell::new(None);
23+
pub(crate) static REACTOR: RefCell<Option<Reactor>> = const { RefCell::new(None) };
2424
}

tests/http_get_json.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
use serde::Deserialize;
2+
use std::error::Error;
3+
use wstd::http::{Client, Request};
4+
use wstd::io::empty;
5+
6+
#[derive(Deserialize)]
7+
struct Echo {
8+
url: String,
9+
}
10+
11+
#[wstd::test]
12+
async fn main() -> Result<(), Box<dyn Error>> {
13+
let request = Request::get("https://postman-echo.com/get").body(empty())?;
14+
15+
let mut response = Client::new().send(request).await?;
16+
17+
let content_type = response
18+
.headers()
19+
.get("Content-Type")
20+
.ok_or_else(|| "response expected to have Content-Type header")?;
21+
assert_eq!(content_type, "application/json; charset=utf-8");
22+
23+
let Echo { url } = response.body_mut().json::<Echo>().await?;
24+
assert!(
25+
url.contains("postman-echo.com/get"),
26+
"expected body url to contain the authority and path, got: {url}"
27+
);
28+
29+
Ok(())
30+
}

tests/http_post_json.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
use serde::{Deserialize, Serialize};
2+
use std::error::Error;
3+
use wstd::http::{request::JsonRequest, Client, Request};
4+
5+
#[derive(Serialize)]
6+
struct TestData {
7+
test: String,
8+
}
9+
10+
#[derive(Deserialize)]
11+
struct Echo {
12+
url: String,
13+
}
14+
15+
#[wstd::test]
16+
async fn main() -> Result<(), Box<dyn Error>> {
17+
let test_data = TestData {
18+
test: "data".to_string(),
19+
};
20+
let request = Request::post("https://postman-echo.com/post").json(&test_data)?;
21+
22+
let content_type = request
23+
.headers()
24+
.get("Content-Type")
25+
.ok_or_else(|| "request expected to have Content-Type header")?;
26+
assert_eq!(content_type, "application/json; charset=utf-8");
27+
28+
let mut response = Client::new().send(request).await?;
29+
30+
let content_type = response
31+
.headers()
32+
.get("Content-Type")
33+
.ok_or_else(|| "response expected to have Content-Type header")?;
34+
assert_eq!(content_type, "application/json; charset=utf-8");
35+
36+
let Echo { url } = response.body_mut().json::<Echo>().await?;
37+
assert!(
38+
url.contains("postman-echo.com/post"),
39+
"expected body url to contain the authority and path, got: {url}"
40+
);
41+
42+
Ok(())
43+
}

0 commit comments

Comments
 (0)