p4: XML request/response support (justapi_core::xml) (ADR-064)

swadhinbiswas committed 3 months ago
82ad4ab
1 parent ae3f01e
commit 82ad4ab
8 files changed
+561 -1
▼BENCHMARKS.md
+19-0
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
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
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
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
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
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
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
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
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
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