api_bones/response.rs
1//! Generic API response envelope types.
2//!
3//! [`ApiResponse<T>`] wraps any payload with consistent metadata so all API
4//! endpoints share the same top-level shape.
5//!
6//! ```json
7//! {
8//! "data": { "id": "abc", "name": "Foo" },
9//! "meta": {
10//! "request_id": "req-123",
11//! "timestamp": "2026-04-06T19:00:00Z",
12//! "version": "1.4.0"
13//! },
14//! "links": [{ "rel": "self", "href": "/resources/abc" }]
15//! }
16//! ```
17//!
18//! # Builder example
19//!
20//! ```rust
21//! use api_bones::response::{ApiResponse, ResponseMeta};
22//!
23//! let response: ApiResponse<&str> = ApiResponse::builder("hello world")
24//! .meta(ResponseMeta::new().request_id("req-001").version("1.0"))
25//! .build();
26//!
27//! assert_eq!(response.data, "hello world");
28//! ```
29
30#[cfg(all(not(feature = "std"), feature = "alloc"))]
31use alloc::string::String;
32#[cfg(feature = "serde")]
33use serde::{Deserialize, Serialize};
34
35use crate::common::Timestamp;
36use crate::links::Links;
37
38// ---------------------------------------------------------------------------
39// ResponseMeta
40// ---------------------------------------------------------------------------
41
42/// Metadata attached to every [`ApiResponse`].
43///
44/// All fields are optional to keep construction ergonomic. Consumers that do
45/// not need a field simply omit it; it will be skipped in serialization.
46#[derive(Debug, Clone, PartialEq, Default)]
47#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
48#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
49#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
50pub struct ResponseMeta {
51 /// Unique identifier for the originating HTTP request.
52 ///
53 /// Useful for correlating logs and distributed traces.
54 #[cfg_attr(
55 feature = "serde",
56 serde(default, skip_serializing_if = "Option::is_none")
57 )]
58 pub request_id: Option<String>,
59
60 /// Server-side timestamp when the response was generated (RFC 3339).
61 #[cfg_attr(
62 feature = "serde",
63 serde(default, skip_serializing_if = "Option::is_none")
64 )]
65 #[cfg_attr(feature = "utoipa", schema(value_type = Option<String>, format = DateTime))]
66 pub timestamp: Option<Timestamp>,
67
68 /// API or service version string.
69 #[cfg_attr(
70 feature = "serde",
71 serde(default, skip_serializing_if = "Option::is_none")
72 )]
73 pub version: Option<String>,
74}
75
76impl ResponseMeta {
77 /// Create an empty `ResponseMeta`.
78 ///
79 /// # Examples
80 ///
81 /// ```rust
82 /// use api_bones::response::ResponseMeta;
83 ///
84 /// let meta = ResponseMeta::new();
85 /// assert!(meta.request_id.is_none());
86 /// assert!(meta.version.is_none());
87 /// ```
88 #[must_use]
89 pub fn new() -> Self {
90 Self::default()
91 }
92
93 /// Set the `request_id` field (builder-style).
94 ///
95 /// # Examples
96 ///
97 /// ```rust
98 /// use api_bones::response::ResponseMeta;
99 ///
100 /// let meta = ResponseMeta::new().request_id("req-001");
101 /// assert_eq!(meta.request_id.as_deref(), Some("req-001"));
102 /// ```
103 #[must_use]
104 pub fn request_id(mut self, id: impl Into<String>) -> Self {
105 self.request_id = Some(id.into());
106 self
107 }
108
109 /// Set the `timestamp` field (builder-style).
110 #[must_use]
111 pub fn timestamp(mut self, ts: Timestamp) -> Self {
112 self.timestamp = Some(ts);
113 self
114 }
115
116 /// Set the `version` field (builder-style).
117 ///
118 /// # Examples
119 ///
120 /// ```rust
121 /// use api_bones::response::ResponseMeta;
122 ///
123 /// let meta = ResponseMeta::new().version("1.4.0");
124 /// assert_eq!(meta.version.as_deref(), Some("1.4.0"));
125 /// ```
126 #[must_use]
127 pub fn version(mut self, v: impl Into<String>) -> Self {
128 self.version = Some(v.into());
129 self
130 }
131}
132
133// ---------------------------------------------------------------------------
134// arbitrary / proptest impls for ResponseMeta
135// ---------------------------------------------------------------------------
136
137// ResponseMeta contains `Option<Timestamp>` where Timestamp is chrono::DateTime<Utc>
138// (when the `chrono` feature is enabled). Since chrono does not implement
139// arbitrary::Arbitrary or proptest::arbitrary::Arbitrary, we provide hand-rolled
140// impls for both feature combinations.
141
142#[cfg(all(feature = "arbitrary", not(feature = "chrono")))]
143impl<'a> arbitrary::Arbitrary<'a> for ResponseMeta {
144 fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
145 use arbitrary::Arbitrary;
146 Ok(Self {
147 request_id: Arbitrary::arbitrary(u)?,
148 timestamp: Arbitrary::arbitrary(u)?,
149 version: Arbitrary::arbitrary(u)?,
150 })
151 }
152}
153
154#[cfg(all(feature = "arbitrary", feature = "chrono"))]
155impl<'a> arbitrary::Arbitrary<'a> for ResponseMeta {
156 fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
157 use arbitrary::Arbitrary;
158 use chrono::TimeZone as _;
159 let timestamp = if bool::arbitrary(u)? {
160 // Clamp to a valid Unix timestamp range (year 1970–3000)
161 let secs = u.int_in_range(0i64..=32_503_680_000i64)?;
162 chrono::Utc.timestamp_opt(secs, 0).single()
163 } else {
164 None
165 };
166 Ok(Self {
167 request_id: Arbitrary::arbitrary(u)?,
168 timestamp,
169 version: Arbitrary::arbitrary(u)?,
170 })
171 }
172}
173
174#[cfg(all(feature = "proptest", not(feature = "chrono")))]
175impl proptest::arbitrary::Arbitrary for ResponseMeta {
176 type Parameters = ();
177 type Strategy = proptest::strategy::BoxedStrategy<Self>;
178
179 fn arbitrary_with((): ()) -> Self::Strategy {
180 use proptest::prelude::*;
181 (
182 proptest::option::of(any::<String>()),
183 proptest::option::of(any::<String>()),
184 proptest::option::of(any::<String>()),
185 )
186 .prop_map(|(request_id, timestamp, version)| Self {
187 request_id,
188 timestamp,
189 version,
190 })
191 .boxed()
192 }
193}
194
195#[cfg(all(feature = "proptest", feature = "chrono"))]
196impl proptest::arbitrary::Arbitrary for ResponseMeta {
197 type Parameters = ();
198 type Strategy = proptest::strategy::BoxedStrategy<Self>;
199
200 fn arbitrary_with((): ()) -> Self::Strategy {
201 use chrono::TimeZone as _;
202 use proptest::prelude::*;
203 (
204 proptest::option::of(any::<String>()),
205 proptest::option::of(0i64..=32_503_680_000i64),
206 proptest::option::of(any::<String>()),
207 )
208 .prop_map(|(request_id, ts_secs, version)| Self {
209 request_id,
210 timestamp: ts_secs.and_then(|s| chrono::Utc.timestamp_opt(s, 0).single()),
211 version,
212 })
213 .boxed()
214 }
215}
216
217// ---------------------------------------------------------------------------
218// ApiResponse
219// ---------------------------------------------------------------------------
220
221/// Generic API response envelope.
222///
223/// Wraps any payload `T` with consistent metadata so all endpoints share the
224/// same top-level JSON shape. Use [`ApiResponse::builder`] for ergonomic
225/// construction.
226///
227/// # Ergonomic access
228///
229/// `ApiResponse<T>` implements [`Deref<Target = T>`](std::ops::Deref) so method
230/// and field access on the payload works transparently without `.data`:
231///
232/// ```rust
233/// use api_bones::response::ApiResponse;
234///
235/// let r: ApiResponse<String> = ApiResponse::builder("hello".to_string()).build();
236/// assert_eq!(r.len(), 5); // String::len via Deref
237/// assert_eq!(&*r, "hello"); // explicit deref
238/// ```
239///
240/// To move the payload out and discard the envelope:
241///
242/// ```rust
243/// use api_bones::response::ApiResponse;
244///
245/// let r: ApiResponse<i32> = ApiResponse::builder(42).build();
246/// let val: i32 = r.into_inner();
247/// assert_eq!(val, 42);
248/// ```
249///
250/// To access envelope metadata alongside the payload:
251///
252/// ```rust
253/// use api_bones::response::ApiResponse;
254///
255/// let r: ApiResponse<i32> = ApiResponse::builder(1).build();
256/// let _req_id = &r.meta.request_id; // envelope field
257/// let ApiResponse { data, meta, .. } = r; // destructure
258/// ```
259///
260/// [`DerefMut`](std::ops::DerefMut) is intentionally not implemented; mutate
261/// after calling `into_inner()`.
262///
263/// # Composing with `PaginatedResponse`
264///
265/// ```rust
266/// use api_bones::pagination::{PaginatedResponse, PaginationParams};
267/// use api_bones::response::ApiResponse;
268///
269/// let params = PaginationParams::default();
270/// let page = PaginatedResponse::new(vec![1i32, 2, 3], 10, ¶ms);
271/// let response = ApiResponse::builder(page).build();
272/// assert_eq!(response.data.total_count, 10);
273/// ```
274#[derive(Debug, Clone, PartialEq)]
275#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
276#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
277#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
278#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
279#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
280pub struct ApiResponse<T> {
281 /// The primary payload.
282 pub data: T,
283
284 /// Request-level metadata.
285 pub meta: ResponseMeta,
286
287 /// Optional hypermedia links.
288 #[cfg_attr(
289 feature = "serde",
290 serde(default, skip_serializing_if = "Option::is_none")
291 )]
292 pub links: Option<Links>,
293}
294
295// ---------------------------------------------------------------------------
296// ApiResponseBuilder
297// ---------------------------------------------------------------------------
298
299/// Builder for [`ApiResponse`].
300///
301/// Obtain one via [`ApiResponse::builder`].
302///
303/// # Design note — simple builder, not typestate
304///
305/// [`HealthCheckBuilder`](crate::health::HealthCheckBuilder) and
306/// [`ReadinessResponseBuilder`](crate::health::ReadinessResponseBuilder) use a
307/// typestate pattern because they have *multiple required fields* that must all
308/// be provided before the type is valid to construct. `ApiResponseBuilder` is
309/// different: the only required field is `data`, which is supplied at
310/// construction time via [`ApiResponse::builder`]. Everything else (`meta`,
311/// `links`) is optional and has a sensible default, so there are no remaining
312/// required fields for the typestate machinery to enforce. A plain builder is
313/// therefore appropriate here.
314pub struct ApiResponseBuilder<T> {
315 data: T,
316 meta: ResponseMeta,
317 links: Option<Links>,
318}
319
320impl<T> ApiResponseBuilder<T> {
321 /// Set the `meta` field.
322 ///
323 /// # Examples
324 ///
325 /// ```rust
326 /// use api_bones::response::{ApiResponse, ResponseMeta};
327 ///
328 /// let response: ApiResponse<&str> = ApiResponse::builder("hi")
329 /// .meta(ResponseMeta::new().request_id("req-1"))
330 /// .build();
331 /// assert_eq!(response.meta.request_id.as_deref(), Some("req-1"));
332 /// ```
333 #[must_use]
334 pub fn meta(mut self, meta: ResponseMeta) -> Self {
335 self.meta = meta;
336 self
337 }
338
339 /// Set the `links` field.
340 ///
341 /// # Examples
342 ///
343 /// ```rust
344 /// use api_bones::response::ApiResponse;
345 /// use api_bones::links::{Link, Links};
346 ///
347 /// let response: ApiResponse<&str> = ApiResponse::builder("hi")
348 /// .links(Links::new().push(Link::self_link("/items/1")))
349 /// .build();
350 /// assert!(response.links.is_some());
351 /// ```
352 #[must_use]
353 pub fn links(mut self, links: Links) -> Self {
354 self.links = Some(links);
355 self
356 }
357
358 /// Consume the builder and produce an [`ApiResponse`].
359 ///
360 /// # Examples
361 ///
362 /// ```rust
363 /// use api_bones::response::ApiResponse;
364 ///
365 /// let response: ApiResponse<i32> = ApiResponse::builder(42).build();
366 /// assert_eq!(response.data, 42);
367 /// assert!(response.links.is_none());
368 /// ```
369 #[must_use]
370 pub fn build(self) -> ApiResponse<T> {
371 ApiResponse {
372 data: self.data,
373 meta: self.meta,
374 links: self.links,
375 }
376 }
377}
378
379impl<T> ApiResponse<T> {
380 /// Begin building an [`ApiResponse`] with the given `data` payload.
381 #[must_use]
382 pub fn builder(data: T) -> ApiResponseBuilder<T> {
383 ApiResponseBuilder {
384 data,
385 meta: ResponseMeta::default(),
386 links: None,
387 }
388 }
389
390 /// Consume the envelope and return the payload, discarding metadata.
391 ///
392 /// # Examples
393 ///
394 /// ```rust
395 /// use api_bones::response::ApiResponse;
396 ///
397 /// let r: ApiResponse<i32> = ApiResponse::builder(42).build();
398 /// assert_eq!(r.into_inner(), 42);
399 /// ```
400 #[must_use]
401 pub fn into_inner(self) -> T {
402 self.data
403 }
404}
405
406impl<T> std::ops::Deref for ApiResponse<T> {
407 type Target = T;
408
409 fn deref(&self) -> &Self::Target {
410 &self.data
411 }
412}
413
414// ---------------------------------------------------------------------------
415// Tests
416// ---------------------------------------------------------------------------
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421
422 // -----------------------------------------------------------------------
423 // ResponseMeta construction
424 // -----------------------------------------------------------------------
425
426 #[test]
427 fn response_meta_new_is_empty() {
428 let m = ResponseMeta::new();
429 assert!(m.request_id.is_none());
430 assert!(m.timestamp.is_none());
431 assert!(m.version.is_none());
432 }
433
434 #[test]
435 fn response_meta_builder_chain() {
436 let m = ResponseMeta::new().request_id("req-001").version("1.4.0");
437 assert_eq!(m.request_id.as_deref(), Some("req-001"));
438 assert_eq!(m.version.as_deref(), Some("1.4.0"));
439 }
440
441 #[cfg(feature = "chrono")]
442 #[test]
443 fn response_meta_timestamp_builder() {
444 use chrono::Utc;
445 let ts = Utc::now();
446 let m = ResponseMeta::new().timestamp(ts);
447 assert!(m.timestamp.is_some());
448 }
449
450 // -----------------------------------------------------------------------
451 // ApiResponse construction
452 // -----------------------------------------------------------------------
453
454 #[test]
455 fn api_response_builder_minimal() {
456 let r: ApiResponse<i32> = ApiResponse::builder(42).build();
457 assert_eq!(r.data, 42);
458 assert!(r.links.is_none());
459 assert!(r.meta.request_id.is_none());
460 }
461
462 #[test]
463 fn api_response_builder_with_meta_and_links() {
464 use crate::links::{Link, Links};
465 let meta = ResponseMeta::new().request_id("r1").version("2.0");
466 let links = Links::new().push(Link::self_link("/items/1"));
467 let r: ApiResponse<&str> = ApiResponse::builder("payload")
468 .meta(meta)
469 .links(links)
470 .build();
471 assert_eq!(r.data, "payload");
472 assert_eq!(r.meta.request_id.as_deref(), Some("r1"));
473 assert_eq!(r.meta.version.as_deref(), Some("2.0"));
474 assert_eq!(
475 r.links
476 .as_ref()
477 .unwrap()
478 .find("self")
479 .map(|l| l.href.as_str()),
480 Some("/items/1")
481 );
482 }
483
484 // -----------------------------------------------------------------------
485 // Deref / into_inner / From
486 // -----------------------------------------------------------------------
487
488 #[test]
489 fn deref_gives_transparent_payload_access() {
490 let r: ApiResponse<String> = ApiResponse::builder("hello".to_string()).build();
491 assert_eq!(r.len(), 5); // String::len resolved via Deref
492 assert_eq!(&*r, "hello");
493 }
494
495 #[test]
496 fn into_inner_moves_payload() {
497 let r: ApiResponse<i32> = ApiResponse::builder(42).build();
498 assert_eq!(r.into_inner(), 42);
499 }
500
501 #[test]
502 fn api_response_composes_with_paginated_response() {
503 use crate::pagination::{PaginatedResponse, PaginationParams};
504 let params = PaginationParams::default();
505 let page = PaginatedResponse::new(vec![1i32, 2, 3], 10, ¶ms);
506 let r = ApiResponse::builder(page).build();
507 assert_eq!(r.data.total_count, 10);
508 }
509
510 // -----------------------------------------------------------------------
511 // Serde round-trips
512 // -----------------------------------------------------------------------
513
514 #[cfg(feature = "serde")]
515 #[test]
516 fn api_response_serde_round_trip_minimal() {
517 let r: ApiResponse<i32> = ApiResponse::builder(99).build();
518 let json = serde_json::to_value(&r).unwrap();
519 // links omitted when None
520 assert!(json.get("links").is_none());
521 assert_eq!(json["data"], 99);
522 let back: ApiResponse<i32> = serde_json::from_value(json).unwrap();
523 assert_eq!(back, r);
524 }
525
526 #[cfg(feature = "serde")]
527 #[test]
528 fn api_response_serde_round_trip_full() {
529 use crate::links::{Link, Links};
530 let meta = ResponseMeta::new().request_id("abc").version("1.0");
531 let links = Links::new().push(Link::self_link("/x"));
532 let r: ApiResponse<String> = ApiResponse::builder("hello".to_string())
533 .meta(meta)
534 .links(links)
535 .build();
536 let json = serde_json::to_value(&r).unwrap();
537 assert_eq!(json["data"], "hello");
538 assert_eq!(json["meta"]["request_id"], "abc");
539 // links::Links serializes as a transparent array
540 assert!(json["links"].is_array());
541 let back: ApiResponse<String> = serde_json::from_value(json).unwrap();
542 assert_eq!(back, r);
543 }
544
545 #[cfg(feature = "serde")]
546 #[test]
547 fn response_meta_omits_none_fields() {
548 let m = ResponseMeta::new().request_id("id1");
549 let json = serde_json::to_value(&m).unwrap();
550 assert!(json.get("timestamp").is_none());
551 assert!(json.get("version").is_none());
552 assert_eq!(json["request_id"], "id1");
553 }
554}