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;