Skip to main content

kynos_openapi/emit/
mod.rs

1//! Producing an artifact from a [`Document`], at a chosen specification
2//! version.
3//!
4//! The split from [`crate::model`] is the one the architecture asks for: the
5//! model is version-agnostic data, and everything that turns it into bytes at a
6//! particular version lives here. The serde derives stay on the model types
7//! themselves — they are part of how those types are represented, not part of
8//! choosing a version to represent them at.
9
10pub mod downgrade;
11
12use crate::{
13    model::document::{Document, SpecVersion},
14    validate::violation::SpecError,
15};
16
17impl Document {
18    /// Serializes to pretty-printed JSON.
19    ///
20    /// # Errors
21    ///
22    /// Returns an error only if a specification extension holds a value that
23    /// cannot be represented in JSON.
24    pub fn to_json(&self) -> Result<String, serde_json::Error> {
25        serde_json::to_string_pretty(self)
26    }
27
28    /// Serializes to YAML.
29    ///
30    /// # Errors
31    ///
32    /// Returns an error only if a number cannot be written as a YAML number:
33    /// either it is beyond the range of a 64-bit float, or a value holds an
34    /// object shaped like `serde_json`'s private number token whose string is
35    /// no number at all. Both can happen only when `serde_json`'s
36    /// `arbitrary_precision` feature is unified into the build: without it,
37    /// `serde_json` holds no such number, and gives that key no meaning.
38    #[cfg(feature = "yaml")]
39    pub fn to_yaml(&self) -> Result<String, serde_yaml_ng::Error> {
40        // Only a build that writes numbers as token mappings pays for, or is
41        // changed by, the detour through a `Value`: a mapping there holds one
42        // value per key, where the model's own serialization writes every key
43        // it is given.
44        if !yaml_numbers::serialized_as_token() {
45            return serde_yaml_ng::to_string(self);
46        }
47        let mut value = serde_yaml_ng::to_value(self)?;
48        yaml_numbers::restore(&mut value)?;
49        serde_yaml_ng::to_string(&value)
50    }
51
52    /// Produces this document as `version`, refusing a lossy downgrade.
53    ///
54    /// Cargo unifies features across a dependency graph, so a program can find
55    /// itself built with `openapi32` enabled even when it needs to publish a
56    /// 3.1 description. This is the safe way to ask for one: rather than
57    /// dropping 3.2-only constructs and emitting something that misdescribes
58    /// the API, it fails and names what stands in the way.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`SpecError::RequiresV3_2`] when the document uses a construct
63    /// that `version` cannot express.
64    pub fn emit(&self, version: SpecVersion) -> Result<Self, SpecError> {
65        let blockers = downgrade::three_two_only_constructs(self);
66        if !version.supports_3_2() && !blockers.is_empty() {
67            return Err(SpecError::RequiresV3_2 { blockers });
68        }
69
70        let mut emitted = self.clone();
71        version.as_str().clone_into(&mut emitted.openapi);
72        Ok(emitted)
73    }
74}
75
76/// Numbers as YAML writes them, whatever `serde_json` features the build
77/// unifies.
78///
79/// With `serde_json/arbitrary_precision` on anywhere in the graph, a
80/// `serde_json::Number` serializes as a one-field struct named by a private
81/// token, holding its digits as a string. `serde_json`'s own serializer
82/// recognises the token and `serde_yaml_ng`'s writes a mapping. Cargo unifies
83/// the feature across the whole build and a crate cannot `cfg` on a
84/// dependency's features, so this reads the serialized tree instead: it finds
85/// each token mapping and writes the number back in its place.
86#[cfg(feature = "yaml")]
87mod yaml_numbers {
88    use serde::ser::Error as _;
89    use serde_yaml_ng::{Mapping, Number, Value};
90
91    /// The name `serde_json` serializes a number under with
92    /// `arbitrary_precision` on: `TOKEN` in its `number.rs`, which is
93    /// `pub(crate)` and so cannot be named from here.
94    /// `mise run test:arbitrary-precision` holds this copy to it.
95    const TOKEN: &str = "$serde_json::private::Number";
96
97    /// Whether this build serializes a `serde_json::Number` as a token
98    /// mapping, which is whether `arbitrary_precision` is on.
99    pub(super) fn serialized_as_token() -> bool {
100        matches!(
101            serde_yaml_ng::to_value(serde_json::Number::from(0u8)),
102            Ok(Value::Mapping(_))
103        )
104    }
105
106    /// Replaces every token mapping in `value` with the number it holds.
107    pub(super) fn restore(value: &mut Value) -> Result<(), serde_yaml_ng::Error> {
108        match value {
109            Value::Sequence(items) => items.iter_mut().try_for_each(restore),
110            Value::Mapping(mapping) => match token_digits(mapping) {
111                Some(digits) => {
112                    *value = Value::Number(number_from_digits(digits)?);
113                    Ok(())
114                }
115                None => mapping.values_mut().try_for_each(restore),
116            },
117            Value::Tagged(tagged) => restore(&mut tagged.value),
118            Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => Ok(()),
119        }
120    }
121
122    /// The digits `mapping` holds, if it is exactly a token mapping.
123    fn token_digits(mapping: &Mapping) -> Option<&str> {
124        let mut entries = mapping.iter();
125        match (entries.next(), entries.next()) {
126            (Some((Value::String(key), Value::String(digits))), None) if key == TOKEN => {
127                Some(digits)
128            }
129            _ => None,
130        }
131    }
132
133    /// The number `serde_json` holds for `digits` when `arbitrary_precision`
134    /// is off.
135    ///
136    /// An integer that fits `u64` is unsigned, a negative one that fits `i64`
137    /// is signed, and everything else is a float -- including `-0`, which has
138    /// no integer to be. Matching that is what makes YAML under the feature the
139    /// YAML the same source text emits without it.
140    pub(super) fn number_from_digits(digits: &str) -> Result<Number, serde_yaml_ng::Error> {
141        if let Ok(unsigned) = digits.parse::<u64>() {
142            return Ok(Number::from(unsigned));
143        }
144        if let Ok(signed @ i64::MIN..=-1) = digits.parse::<i64>() {
145            return Ok(Number::from(signed));
146        }
147        match digits.parse::<f64>() {
148            Ok(float) if float.is_finite() => Ok(Number::from(float)),
149            // Digits beyond any float, or a token-shaped object built by hand
150            // whose string is no number: neither has a YAML number to be.
151            _ => Err(serde_yaml_ng::Error::custom(format_args!(
152                "a number serde_json holds as `{digits}` cannot be emitted as a YAML number"
153            ))),
154        }
155    }
156}
157
158#[cfg(test)]
159mod tests;