Skip to main content

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, &params);
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, &params);
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}