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}