Skip to main content

api_bones/
openapi.rs

1//! `OpenAPI` schema helpers: [`Example`] wrapper and [`DeprecatedField`] marker.
2//!
3//! ## `Example<T>`
4//!
5//! A transparent newtype that carries a typed value alongside schema metadata
6//! so `OpenAPI` generators can surface inline examples.
7//!
8//! ```rust
9//! use api_bones::openapi::Example;
10//!
11//! let ex: Example<u32> = Example::new(42);
12//! assert_eq!(*ex, 42);
13//! ```
14//!
15//! ## `DeprecatedField`
16//!
17//! A transparent newtype that marks a schema field as deprecated in the
18//! generated `OpenAPI` output and optionally carries a replacement hint.
19//!
20//! ```rust
21//! use api_bones::openapi::DeprecatedField;
22//!
23//! let d = DeprecatedField::new("old_name").with_replacement("new_name");
24//! assert_eq!(d.replacement(), Some("new_name"));
25//! ```
26
27#[cfg(all(not(feature = "std"), feature = "alloc"))]
28use alloc::string::String;
29
30#[cfg(feature = "serde")]
31use serde::{Deserialize, Serialize};
32
33// ---------------------------------------------------------------------------
34// Example<T>
35// ---------------------------------------------------------------------------
36
37/// A transparent wrapper that carries a typed value for `OpenAPI` `example`.
38///
39/// Serializes/deserializes identically to `T` (transparent serde).
40///
41/// When the `utoipa` feature is enabled, the value is exposed as a schema
42/// example on the inner type.  When the `schemars` feature is enabled, the
43/// wrapper delegates to `T`'s schema.
44///
45/// # Examples
46///
47/// ```rust
48/// use api_bones::openapi::Example;
49///
50/// let ex = Example::new("hello");
51/// assert_eq!(*ex, "hello");
52/// assert_eq!(ex.into_inner(), "hello");
53/// ```
54#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
55#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
56#[cfg_attr(feature = "serde", serde(transparent))]
57#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
58#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
59#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
60pub struct Example<T>(pub T);
61
62impl<T> Example<T> {
63    /// Wrap a value as an `Example`.
64    ///
65    /// # Examples
66    ///
67    /// ```rust
68    /// use api_bones::openapi::Example;
69    ///
70    /// let ex = Example::new(42u32);
71    /// assert_eq!(ex.value(), &42);
72    /// ```
73    #[must_use]
74    #[inline]
75    pub const fn new(value: T) -> Self {
76        Self(value)
77    }
78
79    /// Borrow the inner value.
80    #[must_use]
81    #[inline]
82    pub const fn value(&self) -> &T {
83        &self.0
84    }
85
86    /// Consume the wrapper and return the inner value.
87    #[must_use]
88    #[inline]
89    pub fn into_inner(self) -> T {
90        self.0
91    }
92}
93
94impl<T> core::ops::Deref for Example<T> {
95    type Target = T;
96
97    #[inline]
98    fn deref(&self) -> &Self::Target {
99        &self.0
100    }
101}
102
103impl<T> core::ops::DerefMut for Example<T> {
104    #[inline]
105    fn deref_mut(&mut self) -> &mut Self::Target {
106        &mut self.0
107    }
108}
109
110impl<T> From<T> for Example<T> {
111    #[inline]
112    fn from(value: T) -> Self {
113        Self(value)
114    }
115}
116
117// ---------------------------------------------------------------------------
118// DeprecatedField
119// ---------------------------------------------------------------------------
120
121/// A transparent wrapper that marks a schema field as deprecated in the
122/// generated `OpenAPI` output.
123///
124/// The inner value is the **field name** (a string) that is being deprecated.
125/// Optionally carries a replacement hint shown in documentation.
126///
127/// Serializes/deserializes the field name transparently.
128///
129/// # Examples
130///
131/// ```rust
132/// use api_bones::openapi::DeprecatedField;
133///
134/// let d = DeprecatedField::new("legacy_id").with_replacement("resource_id");
135/// assert_eq!(d.field_name(), "legacy_id");
136/// assert_eq!(d.replacement(), Some("resource_id"));
137/// ```
138#[cfg(any(feature = "std", feature = "alloc"))]
139#[derive(Debug, Clone, PartialEq, Eq)]
140#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
141#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
142#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
143pub struct DeprecatedField {
144    /// The name of the deprecated field.
145    pub field_name: String,
146    /// Optional migration hint pointing to the replacement field or endpoint.
147    #[cfg_attr(
148        feature = "serde",
149        serde(default, skip_serializing_if = "Option::is_none")
150    )]
151    pub replacement: Option<String>,
152}
153
154#[cfg(any(feature = "std", feature = "alloc"))]
155impl DeprecatedField {
156    /// Create a new `DeprecatedField` marker.
157    ///
158    /// # Examples
159    ///
160    /// ```rust
161    /// use api_bones::openapi::DeprecatedField;
162    ///
163    /// let d = DeprecatedField::new("old_id");
164    /// assert_eq!(d.field_name(), "old_id");
165    /// assert!(d.replacement().is_none());
166    /// ```
167    #[must_use]
168    pub fn new(field_name: impl Into<String>) -> Self {
169        Self {
170            field_name: field_name.into(),
171            replacement: None,
172        }
173    }
174
175    /// Attach a replacement hint (builder-style).
176    ///
177    /// # Examples
178    ///
179    /// ```rust
180    /// use api_bones::openapi::DeprecatedField;
181    ///
182    /// let d = DeprecatedField::new("old_id").with_replacement("resource_id");
183    /// assert_eq!(d.replacement(), Some("resource_id"));
184    /// ```
185    #[must_use]
186    pub fn with_replacement(mut self, replacement: impl Into<String>) -> Self {
187        self.replacement = Some(replacement.into());
188        self
189    }
190
191    /// The deprecated field name.
192    #[must_use]
193    pub fn field_name(&self) -> &str {
194        &self.field_name
195    }
196
197    /// The optional replacement hint.
198    #[must_use]
199    pub fn replacement(&self) -> Option<&str> {
200        self.replacement.as_deref()
201    }
202}
203
204// ---------------------------------------------------------------------------
205// Tests
206// ---------------------------------------------------------------------------
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn example_new_and_deref() {
214        let ex = Example::new(42u32);
215        assert_eq!(*ex, 42);
216        assert_eq!(ex.value(), &42);
217    }
218
219    #[test]
220    fn example_into_inner() {
221        let ex = Example::new("hello");
222        assert_eq!(ex.into_inner(), "hello");
223    }
224
225    #[test]
226    fn example_from() {
227        let ex: Example<u32> = 7u32.into();
228        assert_eq!(*ex, 7);
229    }
230
231    #[test]
232    fn example_deref_mut() {
233        let mut ex = Example::new(1u32);
234        *ex = 2;
235        assert_eq!(*ex, 2);
236    }
237
238    #[cfg(feature = "serde")]
239    #[test]
240    fn example_transparent_serde() {
241        let ex = Example::new(42u32);
242        let json = serde_json::to_value(&ex).unwrap();
243        // Transparent: serializes as 42, not {"0": 42}
244        assert_eq!(json, serde_json::json!(42));
245        let back: Example<u32> = serde_json::from_value(json).unwrap();
246        assert_eq!(back, ex);
247    }
248
249    #[cfg(any(feature = "std", feature = "alloc"))]
250    #[test]
251    fn deprecated_field_new() {
252        let d = DeprecatedField::new("old_id");
253        assert_eq!(d.field_name(), "old_id");
254        assert!(d.replacement().is_none());
255    }
256
257    #[cfg(any(feature = "std", feature = "alloc"))]
258    #[test]
259    fn deprecated_field_with_replacement() {
260        let d = DeprecatedField::new("old_id").with_replacement("resource_id");
261        assert_eq!(d.replacement(), Some("resource_id"));
262    }
263
264    #[cfg(all(feature = "serde", any(feature = "std", feature = "alloc")))]
265    #[test]
266    fn deprecated_field_serde_omits_none_replacement() {
267        let d = DeprecatedField::new("legacy");
268        let json = serde_json::to_value(&d).unwrap();
269        assert!(json.get("replacement").is_none());
270    }
271
272    #[cfg(all(feature = "serde", any(feature = "std", feature = "alloc")))]
273    #[test]
274    fn deprecated_field_serde_round_trip() {
275        let d = DeprecatedField::new("old").with_replacement("new");
276        let json = serde_json::to_value(&d).unwrap();
277        let back: DeprecatedField = serde_json::from_value(json).unwrap();
278        assert_eq!(back, d);
279    }
280}