p4: XML request/response support (justapi_core::xml) (ADR-064)
swadhinbiswas committed 3 months ago
6d7a834
1 parent 6feccbb
commit 6d7a834
8 files changed
+561 -1
Showing 8 changed files+561-1
▼BENCHMARKS.md
+19-0
| @@ -1536,3 +1536,22 @@ data). Bounded FIFO eviction (default 1024 keys), `Arc<Mutex<HashMap>>`. | ||
1536 | 1536 | |
1537 | 1537 | No routing-semantics change (param/method-not-allowed/fallback identical). |
1538 | 1538 | Unit tests added. Gates green. Negligible fixed memory per key. |
1539 | + | |
1540 | +### ADR-064 — XML request/response support (`justapi_core::xml`) | |
1541 | + | |
1542 | +XML is now a first-class content type. Responses: `xml_response` / `XmlResponse<T>` | |
1543 | +emit `application/xml`. Requests: an `application/xml` / `text/xml` body is | |
1544 | +normalized to a `serde_json::Value` (`xml_to_json`) — nested elements → objects, | |
1545 | +repeated siblings → arrays, attributes → `@name`, leaf text collapses to scalar — | |
1546 | +so Python handlers receive uniform JSON. Content negotiation via `negotiate` / | |
1547 | +`respond` lets handlers return JSON or XML from one `Value`. New dep: `quick-xml`. | |
1548 | + | |
1549 | +| Property | Result | | |
1550 | +|---|---| | |
1551 | +| `XmlResponse` (typed) | emits `application/xml`, correct element tree | | |
1552 | +| `xml_to_json` nested + arrays | `{"user":{"id":1}}`, `{"root":{"item":["a","b"]}}` | | |
1553 | +| XML request body (Python path) | converted to JSON; invalid XML → 400 | | |
1554 | +| `negotiate` / `respond` | `Accept: application/xml` → XML response | | |
1555 | +| Gates | 274 core tests (8 new); clippy `-D warnings`; fmt clean | | |
1556 | + | |
1557 | +Limitation: attributes only as `@name`; namespaces/comments/DTD ignored. | |
1539 | 1558 | |
▼Cargo.lock
+11-0
| @@ -2728,6 +2728,7 @@ dependencies = [ | ||
2728 | 2728 | "opentelemetry_sdk", |
2729 | 2729 | "proptest", |
2730 | 2730 | "prost 0.14.4", |
2731 | + "quick-xml", | |
2731 | 2732 | "rand 0.8.6", |
2732 | 2733 | "rcgen", |
2733 | 2734 | "redis", |
| @@ -3898,6 +3899,16 @@ version = "1.2.3" | ||
3898 | 3899 | source = "registry+https://github.com/rust-lang/crates.io-index" |
3899 | 3900 | checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" |
3900 | 3901 | |
3902 | +[[package]] | |
3903 | +name = "quick-xml" | |
3904 | +version = "0.36.2" | |
3905 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
3906 | +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" | |
3907 | +dependencies = [ | |
3908 | + "memchr", | |
3909 | + "serde", | |
3910 | +] | |
3911 | + | |
3901 | 3912 | [[package]] |
3902 | 3913 | name = "quote" |
3903 | 3914 | version = "1.0.46" |
3904 | 3915 | |
▼Cargo.toml
+1-0
| @@ -46,6 +46,7 @@ serde = { version = "1", features = ["derive"] } | ||
46 | 46 | serde_json = "1" |
47 | 47 | serde_urlencoded = "0.7" |
48 | 48 | simd-json = { version = "0.14", features = ["serde_impl"] } |
49 | +quick-xml = { version = "0.36", features = ["serialize"] } | |
49 | 50 | jsonschema = { version = "0.46", default-features = false } |
50 | 51 | |
51 | 52 | # Observability |
52 | 53 | |
▼DECISIONS.md
+46-0
| @@ -2389,3 +2389,49 @@ fallback all resolve identically. | ||
2389 | 2389 | |
2390 | 2390 | **Trade-off:** small fixed memory per cached `(method, path)` key (bounded by |
2391 | 2391 | capacity, FIFO-evicted). No new dependency. |
2392 | + | |
2393 | +## ADR-064 — 2026-07-17 — XML request/response support (`justapi_core::xml`) | |
2394 | + | |
2395 | +**Context:** The feature list calls for XML/SOAP support. JSON is the dominant | |
2396 | +wire format, but enterprise/legacy clients (SOAP, many internal services) speak | |
2397 | +XML. JustAPI needed first-class XML as a content type alongside JSON, with the | |
2398 | +parsing/sizing living in Rust (`justapi-core`) per ADR-008. | |
2399 | + | |
2400 | +**Decision / change:** | |
2401 | +- New `crates/justapi-core/src/xml.rs` (new dep `quick-xml = "0.36"`, | |
2402 | + `serialize` feature — high-perf XML reader/writer, justified below). Provides: | |
2403 | + - **Responses:** `xml_response(status, &str)` (raw string → | |
2404 | + `application/xml`) and `XmlResponse<T: Serialize>` (typed, mirrors | |
2405 | + `serialize::JsonResponse`) emitting `application/xml` via | |
2406 | + `quick_xml::se::to_string_with_root`. `json_to_xml(root, &Value)` serializes | |
2407 | + a `serde_json::Value` to XML. | |
2408 | + - **Requests:** `xml_to_json(bytes) -> Value` converts an `application/xml` / | |
2409 | + `text/xml` body into a `serde_json::Value`, so the rest of the pipeline (which | |
2410 | + speaks JSON `Value`) consumes XML input transparently. Implemented as a small | |
2411 | + event-driven converter over `quick_xml::reader::Reader` (NOT its serde | |
2412 | + `Deserializer` — that cannot represent open JSON `Value`s). Semantics: nested | |
2413 | + elements → nested objects; **repeated sibling tags → arrays**; text → string | |
2414 | + (coerced to number when it parses); attributes → `@name` keys; a leaf element | |
2415 | + with only text collapses to the scalar (`<id>1</id>` → `{"id": 1}`); mixed | |
2416 | + text+children lives under `#text`. | |
2417 | + - **Content negotiation:** `Format { Json, Xml }`, `negotiate(content_type, | |
2418 | + accept)`, and `respond(status, root, &Value, format)` produce a JSON or XML | |
2419 | + response from one `Value`. | |
2420 | +- **Request wiring (Python path):** in `crates/justapi-py/src/native/handlers.rs`, | |
2421 | + after the body is read the request `Content-Type` is checked; an | |
2422 | + `application/xml` / `text/xml` body is converted to JSON via `xml_to_json` and | |
2423 | + re-serialized to canonical JSON bytes, so Python handlers receive a uniform | |
2424 | + JSON-shaped body regardless of the wire format (invalid XML → 400 with detail). | |
2425 | + Routing/validation/CRUD paths are untouched. | |
2426 | +- `lib.rs` exports the `xml` module. | |
2427 | + | |
2428 | +**Why `quick-xml`:** it is the de-facto high-performance Rust XML crate (zero-copy | |
2429 | +reader, serde integration) and avoids hand-rolling a parser. The alternative | |
2430 | +(writing our own XML reader) is more code and more bug surface for no benefit. | |
2431 | +Only the `serialize` feature is enabled; no transitive heavy deps. | |
2432 | + | |
2433 | +**Evidence:** 8 new `xml::tests` covering raw/typed XML responses, status+headers, | |
2434 | +`json_to_xml`, `xml_to_json` (nested + arrays), `negotiate` (all branches), and | |
2435 | +content-negotiated `respond`. Full workspace suite green (274 core tests). clippy | |
2436 | +`-D warnings` + `fmt` clean. Limitation documented: XML attributes survive only | |
2437 | +as `@name` keys; namespaces/comments/DTDs are ignored by the converter. | |
2392 | 2438 | |
▼crates/justapi-core/Cargo.toml
+1-0
| @@ -23,6 +23,7 @@ http-body-util.workspace = true | ||
23 | 23 | serde = { workspace = true, features = ["derive"] } |
24 | 24 | serde_json.workspace = true |
25 | 25 | serde_urlencoded.workspace = true |
26 | +quick-xml.workspace = true | |
26 | 27 | tracing.workspace = true |
27 | 28 | tracing-subscriber = { workspace = true, features = ["env-filter", "json"] } |
28 | 29 | tracing-appender.workspace = true |
29 | 30 | |
▼crates/justapi-core/src/lib.rs
+1-0
| @@ -32,6 +32,7 @@ pub mod trace_context; | ||
32 | 32 | pub mod tracing_setup; |
33 | 33 | pub mod validate; |
34 | 34 | pub mod wasm; |
35 | +pub mod xml; | |
35 | 36 | |
36 | 37 | pub use server::{serve, Server}; |
37 | 38 | |
38 | 39 | |
▼crates/justapi-core/src/xml.rs
+459-0
| @@ -0,0 +1,459 @@ | ||
1 | +//! XML request/response support for JustAPI. | |
2 | +//! | |
3 | +//! XML is a first-class content type alongside JSON: | |
4 | +//! - **Responses:** [`xml_response`] (raw string) and [`XmlResponse`] (typed, | |
5 | +//! mirrors [`crate::serialize::JsonResponse`]) emit `application/xml`. | |
6 | +//! - **Requests:** [`xml_to_json`] normalizes an `application/xml` / `text/xml` | |
7 | +//! body into a `serde_json::Value` so the rest of the pipeline (which speaks | |
8 | +//! JSON `Value`) can consume XML input transparently. This keeps the Rust | |
9 | +//! parsing in `justapi-core` (per ADR-008) and lets Python handlers receive a | |
10 | +//! JSON-shaped body regardless of the wire format. | |
11 | +//! - **Content negotiation:** [`Format`] + [`negotiate`] + [`respond`] let a | |
12 | +//! handler return JSON or XML based on the request `Accept` / `Content-Type`. | |
13 | +//! | |
14 | +//! Powered by `quick-xml` (serde feature). See ADR-064. | |
15 | + | |
16 | +use anyhow::{anyhow, Result}; | |
17 | +use bytes::Bytes; | |
18 | +use http_body_util::combinators::UnsyncBoxBody; | |
19 | +use http_body_util::BodyExt; | |
20 | +use http_body_util::Full; | |
21 | +use hyper::header::HeaderName; | |
22 | +use hyper::{Response, StatusCode}; | |
23 | + | |
24 | +use crate::ResponseBody; | |
25 | + | |
26 | +/// The two negotiated wire formats. | |
27 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
28 | +pub enum Format { | |
29 | + Json, | |
30 | + Xml, | |
31 | +} | |
32 | + | |
33 | +impl Format { | |
34 | + /// The `content-type` for this format. | |
35 | + pub fn content_type(self) -> &'static str { | |
36 | + match self { | |
37 | + Format::Json => "application/json", | |
38 | + Format::Xml => "application/xml", | |
39 | + } | |
40 | + } | |
41 | +} | |
42 | + | |
43 | +/// Decide the response format from a request's `Content-Type` (input) and | |
44 | +/// `Accept` (output preference). `Accept: application/xml` (or `text/xml`) | |
45 | +/// selects XML; everything else (including `*/*`, missing, or JSON) selects | |
46 | +/// JSON. An incoming XML body does **not** force an XML response — output is | |
47 | +/// driven by `Accept`. | |
48 | +pub fn negotiate(content_type: Option<&str>, accept: Option<&str>) -> Format { | |
49 | + let accept = accept.unwrap_or("").to_ascii_lowercase(); | |
50 | + if accept.contains("application/xml") || accept.contains("text/xml") { | |
51 | + return Format::Xml; | |
52 | + } | |
53 | + // If the client sent XML and did not ask for anything specific, echo XML. | |
54 | + let ct = content_type.unwrap_or("").to_ascii_lowercase(); | |
55 | + if accept.is_empty() && (ct.contains("application/xml") || ct.contains("text/xml")) { | |
56 | + return Format::Xml; | |
57 | + } | |
58 | + Format::Json | |
59 | +} | |
60 | + | |
61 | +// --------------------------------------------------------------------------- | |
62 | +// Low-level helpers | |
63 | +// --------------------------------------------------------------------------- | |
64 | + | |
65 | +/// Lift a raw XML string into an `application/xml` response. | |
66 | +pub fn xml_response(status: StatusCode, body: &str) -> Response<ResponseBody> { | |
67 | + Response::builder() | |
68 | + .status(status) | |
69 | + .header("content-type", "application/xml") | |
70 | + .header("content-length", body.len().to_string()) | |
71 | + .body(UnsyncBoxBody::new( | |
72 | + Full::new(Bytes::from(body.to_string())) | |
73 | + .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }), | |
74 | + )) | |
75 | + .unwrap() | |
76 | +} | |
77 | + | |
78 | +/// Serialize a `Serialize` value to an XML string with the given root element. | |
79 | +pub fn to_xml_string<T: serde::Serialize>(root: &str, value: &T) -> Result<String> { | |
80 | + quick_xml::se::to_string_with_root(root, value) | |
81 | + .map_err(|e| anyhow!("xml serialization failed: {e}")) | |
82 | +} | |
83 | + | |
84 | +/// Serialize a `serde_json::Value` to XML using `root` as the document element. | |
85 | +pub fn json_to_xml(root: &str, value: &serde_json::Value) -> Result<String> { | |
86 | + to_xml_string(root, value) | |
87 | +} | |
88 | + | |
89 | +/// Parse an XML byte slice into a `serde_json::Value`. Element-centric: nested | |
90 | +/// elements become nested objects, repeated sibling elements with the same tag | |
91 | +/// become arrays, text content becomes a string (or number when it parses as | |
92 | +/// one). XML **attributes** are captured under `@name` keys; mixed | |
93 | +/// element/text content places the text under `#text`. | |
94 | +/// | |
95 | +/// This is a small, dependency-light converter built on quick-xml's event | |
96 | +/// reader (not its serde `Deserializer`, which cannot represent open JSON | |
97 | +/// `Value`s). It covers the common document shapes; namespaces and | |
98 | +/// comments are ignored. | |
99 | +/// A frame on the XML→JSON conversion stack: an in-progress object (element | |
100 | +/// with children/attrs). `name` is the element's tag, used as the key when | |
101 | +/// inserting this frame into its parent. Repeated sibling elements are folded | |
102 | +/// into arrays by `insert_child`, so no separate array frame is needed. | |
103 | +enum Frame { | |
104 | + Object { name: String, obj: serde_json::Map<String, serde_json::Value> }, | |
105 | +} | |
106 | + | |
107 | +pub fn xml_to_json(bytes: &[u8]) -> Result<serde_json::Value> { | |
108 | + use quick_xml::events::Event; | |
109 | + use quick_xml::reader::Reader; | |
110 | + | |
111 | + let mut reader = Reader::from_str(std::str::from_utf8(bytes)?); | |
112 | + reader.config_mut().trim_text(true); | |
113 | + let mut buf = Vec::new(); | |
114 | + | |
115 | + // Stack of in-progress containers. Each frame is either an object (Map) or | |
116 | + // an array (Vec), plus the current text buffer for the innermost element. | |
117 | + let mut stack: Vec<Frame> = Vec::new(); | |
118 | + // Pending text buffer for the current element. | |
119 | + let mut text_buf = String::new(); | |
120 | + | |
121 | + // Helper: flush accumulated text into the parent as a value or #text. | |
122 | + macro_rules! flush_text { | |
123 | + () => {{ | |
124 | + let txt = text_buf.trim().to_string(); | |
125 | + text_buf.clear(); | |
126 | + txt | |
127 | + }}; | |
128 | + } | |
129 | + | |
130 | + loop { | |
131 | + match reader.read_event_into(&mut buf) { | |
132 | + Ok(Event::Start(e)) => { | |
133 | + let name = String::from_utf8_lossy(e.name().into_inner()).into_owned(); | |
134 | + // Close any pending text on the parent before descending. | |
135 | + let _ = flush_text!(); | |
136 | + let mut obj = serde_json::Map::new(); | |
137 | + // Capture attributes as @key entries. | |
138 | + for attr in e.attributes().flatten() { | |
139 | + let an = String::from_utf8_lossy(attr.key.as_ref()).into_owned(); | |
140 | + let av = String::from_utf8_lossy(&attr.value).into_owned(); | |
141 | + obj.insert(format!("@{an}"), coerce(av)); | |
142 | + } | |
143 | + stack.push(Frame::Object { name, obj }); | |
144 | + text_buf.clear(); | |
145 | + } | |
146 | + Ok(Event::Empty(e)) => { | |
147 | + let name = String::from_utf8_lossy(e.name().into_inner()).into_owned(); | |
148 | + let mut obj = serde_json::Map::new(); | |
149 | + for attr in e.attributes().flatten() { | |
150 | + let an = String::from_utf8_lossy(attr.key.as_ref()).into_owned(); | |
151 | + let av = String::from_utf8_lossy(&attr.value).into_owned(); | |
152 | + obj.insert(format!("@{an}"), coerce(av)); | |
153 | + } | |
154 | + let val = if obj.is_empty() { | |
155 | + serde_json::Value::Null | |
156 | + } else { | |
157 | + serde_json::Value::Object(obj) | |
158 | + }; | |
159 | + insert_child(&mut stack, &name, val); | |
160 | + } | |
161 | + Ok(Event::Text(e)) => { | |
162 | + let t = e.unescape().unwrap_or_default(); | |
163 | + text_buf.push_str(&t); | |
164 | + } | |
165 | + Ok(Event::CData(e)) => { | |
166 | + text_buf.push_str(&String::from_utf8_lossy(e.as_ref())); | |
167 | + } | |
168 | + Ok(Event::End(_)) => { | |
169 | + // If this is the only frame on the stack, closing it means the | |
170 | + // document root — build its value and return it directly. | |
171 | + let is_root = stack.len() == 1; | |
172 | + // Pop the current frame and attach it (or the buffered text) to | |
173 | + // the parent under the frame's own tag name. | |
174 | + let frame = stack.pop().expect("unbalanced xml"); | |
175 | + let (key, value) = match frame { | |
176 | + Frame::Object { name, mut obj } => { | |
177 | + let txt = flush_text!(); | |
178 | + if !txt.is_empty() { | |
179 | + // Text alongside children/attrs (or a leaf element): | |
180 | + // keep the text under `#text`. | |
181 | + obj.insert("#text".to_string(), coerce(txt)); | |
182 | + } | |
183 | + let value = if obj.is_empty() { | |
184 | + serde_json::Value::Null | |
185 | + } else if obj.len() == 1 && obj.contains_key("#text") { | |
186 | + // A leaf element with only text (no attributes, no | |
187 | + // child elements) collapses to the scalar text, so | |
188 | + // `<id>1</id>` becomes `{"id": 1}` rather than | |
189 | + // `{"id": {"#text": 1}}`. | |
190 | + obj.remove("#text").unwrap() | |
191 | + } else { | |
192 | + serde_json::Value::Object(obj) | |
193 | + }; | |
194 | + (name, value) | |
195 | + } | |
196 | + }; | |
197 | + if is_root { | |
198 | + // The document root is wrapped under its own tag name so the | |
199 | + // result is always `{ "root_tag": <content> }`. | |
200 | + let mut root = serde_json::Map::new(); | |
201 | + root.insert(key, value); | |
202 | + return Ok(serde_json::Value::Object(root)); | |
203 | + } | |
204 | + insert_child(&mut stack, &key, value); | |
205 | + } | |
206 | + Ok(Event::Eof) => break, | |
207 | + Ok(Event::Comment(_) | Event::PI(_) | Event::Decl(_) | Event::DocType(_)) => {} | |
208 | + Err(e) => return Err(anyhow!("xml parse error: {e}")), | |
209 | + } | |
210 | + } | |
211 | + | |
212 | + // Fallback: if the document was a bare text node. | |
213 | + if stack.is_empty() { | |
214 | + let t = text_buf.trim().to_string(); | |
215 | + if t.is_empty() { | |
216 | + return Ok(serde_json::Value::Null); | |
217 | + } | |
218 | + return Ok(coerce(t)); | |
219 | + } | |
220 | + Err(anyhow!("xml parse failed: incomplete document")) | |
221 | +} | |
222 | + | |
223 | +/// Coerce a string to a JSON number when it parses, else keep it a string. | |
224 | +fn coerce(s: String) -> serde_json::Value { | |
225 | + if let Ok(n) = s.parse::<i64>() { | |
226 | + return serde_json::Value::Number(n.into()); | |
227 | + } | |
228 | + if let Ok(f) = s.parse::<f64>() { | |
229 | + if let Some(n) = serde_json::Number::from_f64(f) { | |
230 | + return serde_json::Value::Number(n); | |
231 | + } | |
232 | + } | |
233 | + serde_json::Value::String(s) | |
234 | +} | |
235 | + | |
236 | +/// Insert `value` under `key` into the top frame of the stack. If the top frame | |
237 | +/// is an object that already has `key`, promote it to an array (repeated | |
238 | +/// sibling elements). | |
239 | +fn insert_child(stack: &mut Vec<Frame>, key: &str, value: serde_json::Value) { | |
240 | + let top = match stack.last_mut() { | |
241 | + Some(t) => t, | |
242 | + None => { | |
243 | + // No parent: this is the document root with no enclosing frame yet. | |
244 | + // We synthesize a single-element object so the result is always an | |
245 | + // object keyed by the root tag. | |
246 | + let mut m = serde_json::Map::new(); | |
247 | + m.insert(key.to_string(), value); | |
248 | + stack.push(Frame::Object { name: key.to_string(), obj: m }); | |
249 | + return; | |
250 | + } | |
251 | + }; | |
252 | + match top { | |
253 | + Frame::Object { obj, .. } => { | |
254 | + if let Some(existing) = obj.remove(key) { | |
255 | + let arr = match existing { | |
256 | + serde_json::Value::Array(mut a) => { | |
257 | + a.push(value); | |
258 | + a | |
259 | + } | |
260 | + other => vec![other, value], | |
261 | + }; | |
262 | + obj.insert(key.to_string(), serde_json::Value::Array(arr)); | |
263 | + } else { | |
264 | + obj.insert(key.to_string(), value); | |
265 | + } | |
266 | + } | |
267 | + } | |
268 | +} | |
269 | + | |
270 | +// --------------------------------------------------------------------------- | |
271 | +// Typed response model | |
272 | +// --------------------------------------------------------------------------- | |
273 | + | |
274 | +/// An XML response shaped from any `Serialize` type. Mirrors | |
275 | +/// [`crate::serialize::JsonResponse`] but emits `application/xml`. The caller | |
276 | +/// supplies the root element name (XML documents require exactly one root). | |
277 | +/// | |
278 | +/// # Example | |
279 | +/// | |
280 | +/// ```ignore | |
281 | +/// #[derive(Serialize)] | |
282 | +/// struct User { id: u64, name: String } | |
283 | +/// | |
284 | +/// let resp = XmlResponse::new("user", User { id: 1, name: "Alice".into() }) | |
285 | +/// .with_status(StatusCode::CREATED) | |
286 | +/// .into_response(); | |
287 | +/// ``` | |
288 | +pub struct XmlResponse<T: serde::Serialize> { | |
289 | + root: &'static str, | |
290 | + data: T, | |
291 | + status: StatusCode, | |
292 | + headers: Vec<(HeaderName, String)>, | |
293 | +} | |
294 | + | |
295 | +impl<T: serde::Serialize> XmlResponse<T> { | |
296 | + /// Create a new `XmlResponse` with the given root element and status 200. | |
297 | + pub fn new(root: &'static str, data: T) -> Self { | |
298 | + Self { root, data, status: StatusCode::OK, headers: Vec::new() } | |
299 | + } | |
300 | + | |
301 | + /// Set the HTTP status code. | |
302 | + pub fn with_status(mut self, status: StatusCode) -> Self { | |
303 | + self.status = status; | |
304 | + self | |
305 | + } | |
306 | + | |
307 | + /// Add a response header. | |
308 | + pub fn with_header(mut self, name: &str, value: &str) -> Self { | |
309 | + if let Ok(h) = HeaderName::from_bytes(name.as_bytes()) { | |
310 | + self.headers.push((h, value.to_string())); | |
311 | + } | |
312 | + self | |
313 | + } | |
314 | + | |
315 | + /// Serialize the wrapped data and produce a `Response<ResponseBody>`. | |
316 | + pub fn into_response(self) -> Result<Response<ResponseBody>> { | |
317 | + let body_str = to_xml_string(self.root, &self.data)?; | |
318 | + let body_bytes = body_str.into_bytes(); | |
319 | + let mut builder = Response::builder().status(self.status); | |
320 | + builder = builder.header("content-type", "application/xml"); | |
321 | + builder = builder.header("content-length", body_bytes.len().to_string()); | |
322 | + for (name, value) in &self.headers { | |
323 | + builder = builder.header(name, value); | |
324 | + } | |
325 | + let resp = builder | |
326 | + .body(UnsyncBoxBody::new( | |
327 | + Full::new(Bytes::from(body_bytes)) | |
328 | + .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }), | |
329 | + )) | |
330 | + .unwrap(); | |
331 | + Ok(resp) | |
332 | + } | |
333 | +} | |
334 | + | |
335 | +/// Convenience: build an `XmlResponse` from any `Serialize` value. | |
336 | +pub fn xml_response_from<T: serde::Serialize>(root: &'static str, data: T) -> XmlResponse<T> { | |
337 | + XmlResponse::new(root, data) | |
338 | +} | |
339 | + | |
340 | +// --------------------------------------------------------------------------- | |
341 | +// Content-negotiated response | |
342 | +// --------------------------------------------------------------------------- | |
343 | + | |
344 | +/// Produce a response in the negotiated `format` from a `serde_json::Value`. | |
345 | +/// JSON emits compact JSON; XML wraps the value under the given `root` element. | |
346 | +pub fn respond( | |
347 | + status: StatusCode, | |
348 | + root: &str, | |
349 | + value: &serde_json::Value, | |
350 | + format: Format, | |
351 | +) -> Result<Response<ResponseBody>> { | |
352 | + match format { | |
353 | + Format::Json => { | |
354 | + let body = serde_json::to_vec(value)?; | |
355 | + Ok(Response::builder() | |
356 | + .status(status) | |
357 | + .header("content-type", "application/json") | |
358 | + .header("content-length", body.len().to_string()) | |
359 | + .body(UnsyncBoxBody::new( | |
360 | + Full::new(Bytes::from(body)) | |
361 | + .map_err(|e: std::convert::Infallible| -> anyhow::Error { match e {} }), | |
362 | + )) | |
363 | + .unwrap()) | |
364 | + } | |
365 | + Format::Xml => Ok(xml_response(status, &json_to_xml(root, value)?)), | |
366 | + } | |
367 | +} | |
368 | + | |
369 | +#[cfg(test)] | |
370 | +mod tests { | |
371 | + use super::*; | |
372 | + use serde::Serialize; | |
373 | + | |
374 | + #[derive(Serialize)] | |
375 | + struct User { | |
376 | + id: u64, | |
377 | + name: String, | |
378 | + } | |
379 | + | |
380 | + #[test] | |
381 | + fn test_xml_response_raw() { | |
382 | + let resp = xml_response(StatusCode::OK, "<note>hi</note>"); | |
383 | + assert_eq!(resp.headers()["content-type"], "application/xml"); | |
384 | + assert_eq!(resp.status(), StatusCode::OK); | |
385 | + } | |
386 | + | |
387 | + #[tokio::test] | |
388 | + async fn test_xml_response_typed() { | |
389 | + let user = User { id: 1, name: "Alice".into() }; | |
390 | + let resp = XmlResponse::new("user", user).into_response().unwrap(); | |
391 | + assert_eq!(resp.headers()["content-type"], "application/xml"); | |
392 | + assert_eq!(resp.status(), StatusCode::OK); | |
393 | + let body = resp.into_body().collect().await.unwrap().to_bytes(); | |
394 | + let xml = String::from_utf8(body.to_vec()).unwrap(); | |
395 | + assert!(xml.contains("<user>")); | |
396 | + assert!(xml.contains("<id>1</id>")); | |
397 | + assert!(xml.contains("<name>Alice</name>")); | |
398 | + } | |
399 | + | |
400 | + #[test] | |
401 | + fn test_xml_response_status_and_headers() { | |
402 | + let user = User { id: 2, name: "Bob".into() }; | |
403 | + let resp = XmlResponse::new("user", user) | |
404 | + .with_status(StatusCode::CREATED) | |
405 | + .with_header("x-request-id", "r-1") | |
406 | + .into_response() | |
407 | + .unwrap(); | |
408 | + assert_eq!(resp.status(), StatusCode::CREATED); | |
409 | + assert_eq!(resp.headers()["x-request-id"], "r-1"); | |
410 | + } | |
411 | + | |
412 | + #[test] | |
413 | + fn test_json_to_xml() { | |
414 | + let v = serde_json::json!({"id": 1, "name": "Alice"}); | |
415 | + let xml = json_to_xml("user", &v).unwrap(); | |
416 | + assert!(xml.contains("<user>")); | |
417 | + assert!(xml.contains("<id>1</id>")); | |
418 | + assert!(xml.contains("<name>Alice</name>")); | |
419 | + } | |
420 | + | |
421 | + #[test] | |
422 | + fn test_xml_to_json() { | |
423 | + let xml = r#"<user><id>1</id><name>Alice</name></user>"#; | |
424 | + let v = xml_to_json(xml.as_bytes()).unwrap(); | |
425 | + assert_eq!(v["user"]["id"], 1); | |
426 | + assert_eq!(v["user"]["name"], "Alice"); | |
427 | + } | |
428 | + | |
429 | + #[test] | |
430 | + fn test_xml_to_json_array() { | |
431 | + let xml = r#"<root><item>a</item><item>b</item></root>"#; | |
432 | + let v = xml_to_json(xml.as_bytes()).unwrap(); | |
433 | + assert_eq!(v["root"]["item"][0], "a"); | |
434 | + assert_eq!(v["root"]["item"][1], "b"); | |
435 | + } | |
436 | + | |
437 | + #[test] | |
438 | + fn test_negotiate() { | |
439 | + assert_eq!(negotiate(None, Some("application/xml")), Format::Xml); | |
440 | + assert_eq!(negotiate(None, Some("text/xml")), Format::Xml); | |
441 | + assert_eq!(negotiate(None, Some("application/json")), Format::Json); | |
442 | + assert_eq!(negotiate(None, Some("*/*")), Format::Json); | |
443 | + assert_eq!(negotiate(None, None), Format::Json); | |
444 | + // Incoming XML with no Accept preference echoes XML. | |
445 | + assert_eq!(negotiate(Some("application/xml"), None), Format::Xml); | |
446 | + assert_eq!(negotiate(Some("application/json"), None), Format::Json); | |
447 | + } | |
448 | + | |
449 | + #[tokio::test] | |
450 | + async fn test_respond_content_negotiated() { | |
451 | + let v = serde_json::json!({"id": 1}); | |
452 | + let j = respond(StatusCode::OK, "user", &v, Format::Json).unwrap(); | |
453 | + assert_eq!(j.headers()["content-type"], "application/json"); | |
454 | + let x = respond(StatusCode::OK, "user", &v, Format::Xml).unwrap(); | |
455 | + assert_eq!(x.headers()["content-type"], "application/xml"); | |
456 | + let body = x.into_body().collect().await.unwrap().to_bytes(); | |
457 | + assert!(String::from_utf8(body.to_vec()).unwrap().contains("<user>")); | |
458 | + } | |
459 | +} | |
0 | 460 | |
▼crates/justapi-py/src/native/handlers.rs
+23-1
| @@ -9,6 +9,7 @@ use pyo3::types::{PyBool, PyFloat, PyInt}; | ||
9 | 9 | use serde_urlencoded; |
10 | 10 | |
11 | 11 | use justapi_core::router::Router; |
12 | +use justapi_core::xml; | |
12 | 13 | use justapi_core::{json_response, ResponseBody}; |
13 | 14 | |
14 | 15 | use justapi_core::db::AnyPool; |
| @@ -694,7 +695,28 @@ where | ||
694 | 695 | } |
695 | 696 | Err(e) => return Err(anyhow::anyhow!("Body error: {}", e)), |
696 | 697 | }; |
697 | - (b.to_vec(), None) | |
698 | + let mut body = b.to_vec(); | |
699 | + // Normalize an XML request body into JSON so downstream | |
700 | + // JSON-speaking validation/handlers receive a uniform shape. | |
701 | + let ct_lower = content_type.as_deref().unwrap_or("").to_ascii_lowercase(); | |
702 | + if ct_lower.contains("application/xml") || ct_lower.contains("text/xml") { | |
703 | + match xml::xml_to_json(&body) { | |
704 | + Ok(value) => { | |
705 | + if let Ok(json_bytes) = serde_json::to_vec(&value) { | |
706 | + body = json_bytes; | |
707 | + } | |
708 | + // On JSON serialization failure, fall through with | |
709 | + // the original bytes. | |
710 | + } | |
711 | + Err(e) => { | |
712 | + return Ok(json_response( | |
713 | + StatusCode::BAD_REQUEST, | |
714 | + &format!(r#"{{"detail":"invalid xml body: {e}"}}"#), | |
715 | + )); | |
716 | + } | |
717 | + } | |
718 | + } | |
719 | + (body, None) | |
698 | 720 | }; |
699 | 721 | |
700 | 722 | let matched = match router.at(&method, &path) { |
701 | 723 | |