Skip to content

adding .json() method for better ergonomics similar to reqwest crate #63

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ categories.workspace = true
repository.workspace = true

[features]
default = ["json"]
json = ["dep:serde", "dep:serde_json"]

[dependencies]
futures-core.workspace = true
Expand All @@ -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]
Expand Down Expand Up @@ -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"
Expand Down
43 changes: 40 additions & 3 deletions src/http/body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,8 +31,6 @@ impl BodyKind {
.parse::<u64>()
.map_err(|_| InvalidContentLength)?;
Ok(BodyKind::Fixed(content_length))
} else if headers.contains_key(TRANSFER_ENCODING) {
Ok(BodyKind::Chunked)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what this deletion is for - was there a bug here?

Copy link
Contributor Author

@calvinrp calvinrp Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clippy spotted this. The else block below it returned the same thing, Ok(BodyKind::Chunked)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I didn't even consider that, I was focused on the transfer encoding aspect.

} else {
Ok(BodyKind::Chunked)
}
Expand Down Expand Up @@ -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<T: DeserializeOwned>(&mut self) -> Result<T, Error> {
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<u8>`.
pub async fn bytes(&mut self) -> Result<Vec<u8>, 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 {
Expand Down
6 changes: 6 additions & 0 deletions src/http/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ pub struct Client {
options: Option<RequestOptions>,
}

impl Default for Client {
fn default() -> Self {
Self::new()
}
}

impl Client {
/// Create a new instance of `Client`
pub fn new() -> Self {
Expand Down
2 changes: 1 addition & 1 deletion src/http/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}"),
Expand Down
44 changes: 44 additions & 0 deletions src/http/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Serialize + ?Sized>(self, json: &T) -> Result<Request<BoundedBody<Vec<u8>>>, 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<T: Serialize + ?Sized>(self, json: &T) -> Result<Request<BoundedBody<Vec<u8>>>, 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<T>(request: Request<T>) -> Result<(OutgoingRequest, T), Error> {
let wasi_req = OutgoingRequest::new(header_map_to_wasi(request.headers())?);

Expand Down
2 changes: 1 addition & 1 deletion src/runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<Reactor>> = RefCell::new(None);
pub(crate) static REACTOR: RefCell<Option<Reactor>> = const { RefCell::new(None) };
}
30 changes: 30 additions & 0 deletions tests/http_get_json.rs
Original file line number Diff line number Diff line change
@@ -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<dyn Error>> {
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::<Echo>().await?;
assert!(
url.contains("postman-echo.com/get"),
"expected body url to contain the authority and path, got: {url}"
);

Ok(())
}
43 changes: 43 additions & 0 deletions tests/http_post_json.rs
Original file line number Diff line number Diff line change
@@ -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<dyn Error>> {
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::<Echo>().await?;
assert!(
url.contains("postman-echo.com/post"),
"expected body url to contain the authority and path, got: {url}"
);

Ok(())
}
Loading