From b71d4fa557fe6dfd00ab0b2f6010de4c40fc99a0 Mon Sep 17 00:00:00 2001 From: Sabrina Jewson Date: Sun, 6 Oct 2024 20:01:10 +0100 Subject: [PATCH] Add `axum_extra::json!` (#2962) --- axum-extra/CHANGELOG.md | 6 +++ axum-extra/Cargo.toml | 6 ++- axum-extra/src/response/erased_json.rs | 71 ++++++++++++++++++++++++++ axum-extra/src/response/mod.rs | 5 ++ axum/src/json.rs | 5 ++ 5 files changed, 91 insertions(+), 2 deletions(-) diff --git a/axum-extra/CHANGELOG.md b/axum-extra/CHANGELOG.md index d26c506d..066029ae 100644 --- a/axum-extra/CHANGELOG.md +++ b/axum-extra/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog], and this project adheres to [Semantic Versioning]. +# Unreleased + +- **added:** Add `json!` for easy construction of JSON responses ([#2962]) + +[#2962]: https://github.com/tokio-rs/axum/pull/2962 + # 0.9.4 - **added:** The `response::Attachment` type ([#2789]) diff --git a/axum-extra/Cargo.toml b/axum-extra/Cargo.toml index b297df68..aed676d1 100644 --- a/axum-extra/Cargo.toml +++ b/axum-extra/Cargo.toml @@ -20,7 +20,7 @@ cookie = ["dep:cookie"] cookie-private = ["cookie", "cookie?/private"] cookie-signed = ["cookie", "cookie?/signed"] cookie-key-expansion = ["cookie", "cookie?/key-expansion"] -erased-json = ["dep:serde_json"] +erased-json = ["dep:serde_json", "dep:typed-json"] form = ["dep:serde_html_form"] json-deserializer = ["dep:serde_json", "dep:serde_path_to_error"] json-lines = [ @@ -69,9 +69,11 @@ tokio = { version = "1.19", optional = true } tokio-stream = { version = "0.1.9", optional = true } tokio-util = { version = "0.7", optional = true } tracing = { version = "0.1.37", default-features = false, optional = true } +typed-json = { version = "0.1.1", optional = true } [dev-dependencies] -axum = { path = "../axum", version = "0.7.2" } +axum = { path = "../axum", features = ["macros"] } +axum-macros = { path = "../axum-macros", features = ["__private"] } hyper = "1.0.0" reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "multipart"] } serde = { version = "1.0", features = ["derive"] } diff --git a/axum-extra/src/response/erased_json.rs b/axum-extra/src/response/erased_json.rs index 6e94a267..5088ff35 100644 --- a/axum-extra/src/response/erased_json.rs +++ b/axum-extra/src/response/erased_json.rs @@ -12,6 +12,15 @@ use serde::Serialize; /// This allows returning a borrowing type from a handler, or returning different response /// types as JSON from different branches inside a handler. /// +/// Like [`axum::Json`], +/// if the [`Serialize`] implementation fails +/// or if a map with non-string keys is used, +/// a 500 response will be issued +/// whose body is the error message in UTF-8. +/// +/// This can be constructed using [`new`](ErasedJson::new) +/// or the [`json!`](crate::json) macro. +/// /// # Example /// /// ```rust @@ -72,3 +81,65 @@ impl IntoResponse for ErasedJson { } } } + +/// Construct an [`ErasedJson`] response from a JSON literal. +/// +/// A `Content-Type: application/json` header is automatically added. +/// Any variable or expression implementing [`Serialize`] +/// can be interpolated as a value in the literal. +/// If the [`Serialize`] implementation fails, +/// or if a map with non-string keys is used, +/// a 500 response will be issued +/// whose body is the error message in UTF-8. +/// +/// Internally, +/// this function uses the [`typed_json::json!`] macro, +/// allowing it to perform far fewer allocations +/// than a dynamic macro like [`serde_json::json!`] would – +/// it's equivalent to if you had just written +/// `derive(Serialize)` on a struct. +/// +/// # Examples +/// +/// ``` +/// use axum::{ +/// Router, +/// extract::Path, +/// response::Response, +/// routing::get, +/// }; +/// use axum_extra::response::ErasedJson; +/// +/// async fn get_user(Path(user_id) : Path) -> ErasedJson { +/// let user_name = find_user_name(user_id).await; +/// axum_extra::json!({ "name": user_name }) +/// } +/// +/// async fn find_user_name(user_id: u64) -> String { +/// // ... +/// # unimplemented!() +/// } +/// +/// let app = Router::new().route("/users/{id}", get(get_user)); +/// # let _: Router = app; +/// ``` +/// +/// Trailing commas are allowed in both arrays and objects. +/// +/// ``` +/// let response = axum_extra::json!(["trailing",]); +/// ``` +#[macro_export] +macro_rules! json { + ($($t:tt)*) => { + $crate::response::ErasedJson::new( + $crate::response::__private_erased_json::typed_json::json!($($t)*) + ) + } +} + +/// Not public API. Re-exported as `crate::response::__private_erased_json`. +#[doc(hidden)] +pub mod private { + pub use typed_json; +} diff --git a/axum-extra/src/response/mod.rs b/axum-extra/src/response/mod.rs index 3b4b14c0..04a69ec5 100644 --- a/axum-extra/src/response/mod.rs +++ b/axum-extra/src/response/mod.rs @@ -12,6 +12,11 @@ pub mod multiple; #[cfg(feature = "erased-json")] pub use erased_json::ErasedJson; +/// _not_ public API +#[cfg(feature = "erased-json")] +#[doc(hidden)] +pub use erased_json::private as __private_erased_json; + #[cfg(feature = "json-lines")] #[doc(no_inline)] pub use crate::json_lines::JsonLines; diff --git a/axum/src/json.rs b/axum/src/json.rs index 854ead4e..b1135742 100644 --- a/axum/src/json.rs +++ b/axum/src/json.rs @@ -55,6 +55,11 @@ use serde::{de::DeserializeOwned, Serialize}; /// When used as a response, it can serialize any type that implements [`serde::Serialize`] to /// `JSON`, and will automatically set `Content-Type: application/json` header. /// +/// If the [`Serialize`] implementation decides to fail +/// or if a map with non-string keys is used, +/// a 500 response will be issued +/// whose body is the error message in UTF-8. +/// /// # Response example /// /// ```