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/// # Composing with `PaginatedResponse`
228///
229/// ```rust
230/// use api_bones::pagination::{PaginatedResponse, PaginationParams};
231/// use api_bones::response::ApiResponse;
232///
233/// let params = PaginationParams::default();
234/// let page = PaginatedResponse::new(vec![1i32, 2, 3], 10, &params);
235/// let response = ApiResponse::builder(page).build();
236/// assert_eq!(response.data.total_count, 10);
237/// ```
238#[derive(Debug, Clone, PartialEq)]
239#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
240#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
241#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
242#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
243#[cfg_attr(feature = "proptest", derive(proptest_derive::Arbitrary))]
244pub struct ApiResponse<T> {
245    /// The primary payload.
246    pub data: T,
247
248    /// Request-level metadata.
249    pub meta: ResponseMeta,
250
251    /// Optional hypermedia links.
252    #[cfg_attr(
253        feature = "serde",
254        serde(default, skip_serializing_if = "Option::is_none")
255    )]
256    pub links: Option<Links>,
257}
258
259// ---------------------------------------------------------------------------
260// ApiResponseBuilder
261// ---------------------------------------------------------------------------
262
263/// Builder for [`ApiResponse`].
264///
265/// Obtain one via [`ApiResponse::builder`].
266///
267/// # Design note — simple builder, not typestate
268///
269/// [`HealthCheckBuilder`](crate::health::HealthCheckBuilder) and
270/// [`ReadinessResponseBuilder`](crate::health::ReadinessResponseBuilder) use a
271/// typestate pattern because they have *multiple required fields* that must all
272/// be provided before the type is valid to construct.  `ApiResponseBuilder` is
273/// different: the only required field is `data`, which is supplied at
274/// construction time via [`ApiResponse::builder`].  Everything else (`meta`,
275/// `links`) is optional and has a sensible default, so there are no remaining
276/// required fields for the typestate machinery to enforce.  A plain builder is
277/// therefore appropriate here.
278pub struct ApiResponseBuilder<T> {
279    data: T,
280    meta: ResponseMeta,
281    links: Option<Links>,
282}
283
284impl<T> ApiResponseBuilder<T> {
285    /// Set the `meta` field.
286    ///
287    /// # Examples
288    ///
289    /// ```rust
290    /// use api_bones::response::{ApiResponse, ResponseMeta};
291    ///
292    /// let response: ApiResponse<&str> = ApiResponse::builder("hi")
293    ///     .meta(ResponseMeta::new().request_id("req-1"))
294    ///     .build();
295    /// assert_eq!(response.meta.request_id.as_deref(), Some("req-1"));
296    /// ```
297    #[must_use]
298    pub fn meta(mut self, meta: ResponseMeta) -> Self {
299        self.meta = meta;
300        self
301    }
302
303    /// Set the `links` field.
304    ///
305    /// # Examples
306    ///
307    /// ```rust
308    /// use api_bones::response::ApiResponse;
309    /// use api_bones::links::{Link, Links};
310    ///
311    /// let response: ApiResponse<&str> = ApiResponse::builder("hi")
312    ///     .links(Links::new().push(Link::self_link("/items/1")))
313    ///     .build();
314    /// assert!(response.links.is_some());
315    /// ```
316    #[must_use]
317    pub fn links(mut self, links: Links) -> Self {
318        self.links = Some(links);
319        self
320    }
321
322    /// Consume the builder and produce an [`ApiResponse`].
323    ///
324    /// # Examples
325    ///
326    /// ```rust
327    /// use api_bones::response::ApiResponse;
328    ///
329    /// let response: ApiResponse<i32> = ApiResponse::builder(42).build();
330    /// assert_eq!(response.data, 42);
331    /// assert!(response.links.is_none());
332    /// ```
333    #[must_use]
334    pub fn build(self) -> ApiResponse<T> {
335        ApiResponse {
336            data: self.data,
337            meta: self.meta,
338            links: self.links,
339        }
340    }
341}
342
343impl<T> ApiResponse<T> {
344    /// Begin building an [`ApiResponse`] with the given `data` payload.
345    #[must_use]
346    pub fn builder(data: T) -> ApiResponseBuilder<T> {
347        ApiResponseBuilder {
348            data,
349            meta: ResponseMeta::default(),
350            links: None,
351        }
352    }
353}
354
355// ---------------------------------------------------------------------------
356// Tests
357// ---------------------------------------------------------------------------
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    // -----------------------------------------------------------------------
364    // ResponseMeta construction
365    // -----------------------------------------------------------------------
366
367    #[test]
368    fn response_meta_new_is_empty() {
369        let m = ResponseMeta::new();
370        assert!(m.request_id.is_none());
371        assert!(m.timestamp.is_none());
372        assert!(m.version.is_none());
373    }
374
375    #[test]
376    fn response_meta_builder_chain() {
377        let m = ResponseMeta::new().request_id("req-001").version("1.4.0");
378        assert_eq!(m.request_id.as_deref(), Some("req-001"));
379        assert_eq!(m.version.as_deref(), Some("1.4.0"));
380    }
381
382    #[cfg(feature = "chrono")]
383    #[test]
384    fn response_meta_timestamp_builder() {
385        use chrono::Utc;
386        let ts = Utc::now();
387        let m = ResponseMeta::new().timestamp(ts);
388        assert!(m.timestamp.is_some());
389    }
390
391    // -----------------------------------------------------------------------
392    // ApiResponse construction
393    // -----------------------------------------------------------------------
394
395    #[test]
396    fn api_response_builder_minimal() {
397        let r: ApiResponse<i32> = ApiResponse::builder(42).build();
398        assert_eq!(r.data, 42);
399        assert!(r.links.is_none());
400        assert!(r.meta.request_id.is_none());
401    }
402
403    #[test]
404    fn api_response_builder_with_meta_and_links() {
405        use crate::links::{Link, Links};
406        let meta = ResponseMeta::new().request_id("r1").version("2.0");
407        let links = Links::new().push(Link::self_link("/items/1"));
408        let r: ApiResponse<&str> = ApiResponse::builder("payload")
409            .meta(meta)
410            .links(links)
411            .build();
412        assert_eq!(r.data, "payload");
413        assert_eq!(r.meta.request_id.as_deref(), Some("r1"));
414        assert_eq!(r.meta.version.as_deref(), Some("2.0"));
415        assert_eq!(
416            r.links
417                .as_ref()
418                .unwrap()
419                .find("self")
420                .map(|l| l.href.as_str()),
421            Some("/items/1")
422        );
423    }
424
425    #[test]
426    fn api_response_composes_with_paginated_response() {
427        use crate::pagination::{PaginatedResponse, PaginationParams};
428        let params = PaginationParams::default();
429        let page = PaginatedResponse::new(vec![1i32, 2, 3], 10, &params);
430        let r = ApiResponse::builder(page).build();
431        assert_eq!(r.data.total_count, 10);
432    }
433
434    // -----------------------------------------------------------------------
435    // Serde round-trips
436    // -----------------------------------------------------------------------
437
438    #[cfg(feature = "serde")]
439    #[test]
440    fn api_response_serde_round_trip_minimal() {
441        let r: ApiResponse<i32> = ApiResponse::builder(99).build();
442        let json = serde_json::to_value(&r).unwrap();
443        // links omitted when None
444        assert!(json.get("links").is_none());
445        assert_eq!(json["data"], 99);
446        let back: ApiResponse<i32> = serde_json::from_value(json).unwrap();
447        assert_eq!(back, r);
448    }
449
450    #[cfg(feature = "serde")]
451    #[test]
452    fn api_response_serde_round_trip_full() {
453        use crate::links::{Link, Links};
454        let meta = ResponseMeta::new().request_id("abc").version("1.0");
455        let links = Links::new().push(Link::self_link("/x"));
456        let r: ApiResponse<String> = ApiResponse::builder("hello".to_string())
457            .meta(meta)
458            .links(links)
459            .build();
460        let json = serde_json::to_value(&r).unwrap();
461        assert_eq!(json["data"], "hello");
462        assert_eq!(json["meta"]["request_id"], "abc");
463        // links::Links serializes as a transparent array
464        assert!(json["links"].is_array());
465        let back: ApiResponse<String> = serde_json::from_value(json).unwrap();
466        assert_eq!(back, r);
467    }
468
469    #[cfg(feature = "serde")]
470    #[test]
471    fn response_meta_omits_none_fields() {
472        let m = ResponseMeta::new().request_id("id1");
473        let json = serde_json::to_value(&m).unwrap();
474        assert!(json.get("timestamp").is_none());
475        assert!(json.get("version").is_none());
476        assert_eq!(json["request_id"], "id1");
477    }
478}