Skip to main content

typed_openapi/
scalar.rs

1//! The value kinds a command-line flag can carry, the rules the document states
2//! about them, and how a raw argument string becomes the JSON the wire wants.
3//!
4//! A [`Scalar`] is the only thing this crate knows about a schema: everything
5//! richer than these five kinds is a whole-body affair and never reaches a flag.
6//! What one carries is what the document said — a `pattern`, a length, a bound,
7//! a step — and every one of those is enforced by [`Scalar::parse`], because a
8//! rule that only reaches `--help` is a rule the user finds out about from the
9//! server.
10//!
11//! `pattern` runs on `regress`, the ECMA-262 engine typify puts inside a
12//! generated newtype's `FromStr`. One engine, one rule, one set of bytes: a
13//! value the command line accepts is a value the generated type accepts by
14//! construction, rather than by a hand-written rule kept in step.
15
16use std::fmt;
17
18use regress::Regex;
19use serde::{Deserialize, Serialize};
20use thiserror::Error;
21
22/// What one flag accepts, as the document describes it.
23///
24/// [`Eq`] is absent on purpose: a `number`'s bounds are `f64`, which has none.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub enum Scalar {
27    /// `type: string`, with whatever the document states about it.
28    Text(Text),
29    /// `type: integer`, with whatever the document states about it.
30    Integer(Bounds<i64>),
31    /// `type: number`, with whatever the document states about it.
32    Number(Bounds<f64>),
33    /// `type: boolean`.
34    Boolean,
35    /// `enum: [..]` on a string schema. These values complete.
36    Choice(Vec<String>),
37}
38
39/// What a string schema states beyond being a string.
40///
41/// The fields are public because there is nothing here to keep true: each one
42/// is a JSON Schema keyword read straight off the document, and `None` is the
43/// document saying nothing.
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
45pub struct Text {
46    /// `pattern`, as ECMA-262 spells it.
47    pub pattern: Option<String>,
48    /// `minLength`, counted in characters.
49    pub min_length: Option<usize>,
50    /// `maxLength`, counted in characters.
51    pub max_length: Option<usize>,
52}
53
54/// What a numeric schema states beyond being a number.
55#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
56pub struct Bounds<T> {
57    /// `minimum`, with `exclusiveMinimum` folded into it.
58    pub low: Option<Limit<T>>,
59    /// `maximum`, with `exclusiveMaximum` folded into it.
60    pub high: Option<Limit<T>>,
61    /// `multipleOf`.
62    pub multiple_of: Option<T>,
63}
64
65/// One end of a range.
66///
67/// OpenAPI 3.0 spells exclusivity as a flag beside the number rather than as a
68/// number of its own, which leaves `exclusiveMinimum: true` with no `minimum`
69/// both sayable and meaningless. Folded into one value it cannot be written.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71pub enum Limit<T> {
72    /// The value itself is allowed.
73    Inclusive(T),
74    /// The value itself is not.
75    Exclusive(T),
76}
77
78/// A raw argument the document's schema rejects.
79#[derive(Debug, Clone, Error, PartialEq, Eq)]
80pub enum ScalarError {
81    /// Not the kind of value the document asks for at all.
82    #[error("`{raw}` is not {wanted}")]
83    Kind { raw: String, wanted: &'static str },
84    /// Outside the document's `enum` — the one refusal that can say what to
85    /// type instead, which is why it carries the list rather than a sentence.
86    #[error("`{raw}` is not one of {}", .allowed.join(", "))]
87    Choice { raw: String, allowed: Vec<String> },
88    /// The right kind of value, refused by a rule the document states. `rule`
89    /// finishes the sentence and carries the document's own number, which is
90    /// the only part a user can act on; nothing branches on which keyword it
91    /// came from.
92    #[error("`{raw}` {rule}")]
93    Rule { raw: String, rule: String },
94    /// A `pattern` the engine cannot read. Every value is refused, because a
95    /// rule nobody can run is not a rule everything passed — and a document
96    /// stating one is refused outright while it is reduced.
97    #[error("`{pattern}` is not a regular expression: {message}")]
98    Pattern { pattern: String, message: String },
99}
100
101impl ScalarError {
102    /// `raw`, refused by a rule that finishes the sentence.
103    fn rule(raw: &str, rule: String) -> Self {
104        Self::Rule {
105            raw: raw.to_owned(),
106            rule,
107        }
108    }
109}
110
111impl Scalar {
112    /// Turn one raw argument into the JSON value the wire wants, or refuse it
113    /// the way the document does.
114    pub fn parse(&self, raw: &str) -> Result<serde_json::Value, ScalarError> {
115        match self {
116            Self::Text(text) => text.check(raw).map(|()| raw.into()),
117            Self::Integer(bounds) => bounded(bounds, raw),
118            Self::Number(bounds) => bounded(bounds, raw),
119            Self::Boolean => raw
120                .parse::<bool>()
121                .map(Into::into)
122                .map_err(|_| ScalarError::Kind {
123                    raw: raw.to_owned(),
124                    wanted: "`true` or `false`",
125                }),
126            Self::Choice(allowed) => {
127                if allowed.iter().any(|value| value == raw) {
128                    Ok(raw.into())
129                } else {
130                    Err(ScalarError::Choice {
131                        raw: raw.to_owned(),
132                        allowed: allowed.clone(),
133                    })
134                }
135            }
136        }
137    }
138
139    /// Whether every rule this carries can be run at all.
140    ///
141    /// A `pattern` the engine cannot read is the only rule that can fail this
142    /// way, and it fails on every value — so a document is held to this while
143    /// it is reduced, and the failure names the operation rather than turning
144    /// up at a user's flag.
145    pub fn runnable(&self) -> Result<(), ScalarError> {
146        match self {
147            Self::Text(text) => text.pattern.as_deref().map(compiled).transpose().map(drop),
148            Self::Integer(_) | Self::Number(_) | Self::Boolean | Self::Choice(_) => Ok(()),
149        }
150    }
151
152    /// The `<NAME>` shown for the flag's value, and the closest thing the
153    /// document gives an agent to a type.
154    #[must_use]
155    pub fn value_name(&self) -> &'static str {
156        match self {
157            Self::Text(_) | Self::Choice(_) => "STRING",
158            Self::Integer(_) => "INT",
159            Self::Number(_) => "NUMBER",
160            Self::Boolean => "BOOL",
161        }
162    }
163
164    /// Everything the document constrains about this value, for the flag's help
165    /// line. Every note is a rule [`Scalar::parse`] enforces, in the document's
166    /// own numbers — help and refusal cannot come apart, because one rendering
167    /// serves both.
168    #[must_use]
169    pub fn note(&self) -> Option<String> {
170        let notes = match self {
171            Self::Text(text) => text.notes(),
172            Self::Integer(bounds) => bounds.notes(),
173            Self::Number(bounds) => bounds.notes(),
174            Self::Boolean | Self::Choice(_) => Vec::new(),
175        };
176        (!notes.is_empty()).then(|| notes.join("; "))
177    }
178}
179
180impl Text {
181    /// Hold one argument to every rule the document states about it.
182    fn check(&self, raw: &str) -> Result<(), ScalarError> {
183        // `minLength` and `maxLength` count characters, not bytes.
184        let length = raw.chars().count();
185        if let Some(least) = self.min_length
186            && length < least
187        {
188            return Err(ScalarError::rule(
189                raw,
190                format!("is shorter than {least} characters"),
191            ));
192        }
193        if let Some(most) = self.max_length
194            && length > most
195        {
196            return Err(ScalarError::rule(
197                raw,
198                format!("is longer than {most} characters"),
199            ));
200        }
201        let Some(pattern) = self.pattern.as_deref() else {
202            return Ok(());
203        };
204        // JSON Schema's `pattern` is a search rather than a whole-string match,
205        // so an unanchored pattern matches anywhere in the value. Anchoring it
206        // here would refuse values the document allows.
207        if compiled(pattern)?.find(raw).is_none() {
208            return Err(ScalarError::rule(raw, format!("does not match {pattern}")));
209        }
210        Ok(())
211    }
212
213    fn notes(&self) -> Vec<String> {
214        let mut notes = Vec::new();
215        if let Some(least) = self.min_length {
216            notes.push(format!("at least {least} characters"));
217        }
218        if let Some(most) = self.max_length {
219            notes.push(format!("at most {most} characters"));
220        }
221        if let Some(pattern) = &self.pattern {
222            notes.push(format!("matches {pattern}"));
223        }
224        notes
225    }
226}
227
228impl<T: Numeric> Bounds<T> {
229    /// Hold one value to every rule the document states about it.
230    fn check(&self, value: T, raw: &str) -> Result<(), ScalarError> {
231        let refuse = |rule: String| ScalarError::rule(raw, format!("is not {rule}"));
232        if let Some(low) = self.low {
233            low.floor(value).map_err(&refuse)?;
234        }
235        if let Some(high) = self.high {
236            high.ceiling(value).map_err(&refuse)?;
237        }
238        if let Some(step) = self.multiple_of
239            && !value.divisible_by(step)
240        {
241            return Err(refuse(format!("a multiple of {step}")));
242        }
243        Ok(())
244    }
245
246    fn notes(&self) -> Vec<String> {
247        let mut notes = Vec::new();
248        if let Some(low) = self.low {
249            notes.push(low.note("at least", "more than"));
250        }
251        if let Some(high) = self.high {
252            notes.push(high.note("at most", "less than"));
253        }
254        if let Some(step) = self.multiple_of {
255            notes.push(format!("a multiple of {step}"));
256        }
257        notes
258    }
259}
260
261impl<T: Numeric> Limit<T> {
262    /// This limit as a lower bound. The `Err` is how the rule reads, and it is
263    /// how `--help` reads it too — one rendering, so a refusal cannot describe
264    /// a different rule from the one the help line advertised.
265    fn floor(self, value: T) -> Result<(), String> {
266        let admits = match self {
267            Self::Inclusive(limit) => value >= limit,
268            Self::Exclusive(limit) => value > limit,
269        };
270        if admits {
271            Ok(())
272        } else {
273            Err(self.note("at least", "more than"))
274        }
275    }
276
277    /// This limit as an upper bound.
278    fn ceiling(self, value: T) -> Result<(), String> {
279        let admits = match self {
280            Self::Inclusive(limit) => value <= limit,
281            Self::Exclusive(limit) => value < limit,
282        };
283        if admits {
284            Ok(())
285        } else {
286            Err(self.note("at most", "less than"))
287        }
288    }
289
290    fn note(self, inclusive: &str, exclusive: &str) -> String {
291        match self {
292            Self::Inclusive(limit) => format!("{inclusive} {limit}"),
293            Self::Exclusive(limit) => format!("{exclusive} {limit}"),
294        }
295    }
296}
297
298/// One ECMA-262 pattern, ready to run.
299fn compiled(pattern: &str) -> Result<Regex, ScalarError> {
300    Regex::new(pattern).map_err(|error| ScalarError::Pattern {
301        pattern: pattern.to_owned(),
302        message: error.to_string(),
303    })
304}
305
306/// One argument as a number the document's rules admit.
307fn bounded<T: Numeric>(bounds: &Bounds<T>, raw: &str) -> Result<serde_json::Value, ScalarError> {
308    let (value, json) = T::read(raw).ok_or_else(|| ScalarError::Kind {
309        raw: raw.to_owned(),
310        wanted: T::KIND,
311    })?;
312    bounds.check(value, raw)?;
313    Ok(json)
314}
315
316/// The two number kinds a document can ask one flag for.
317///
318/// A trait rather than two copies of [`Bounds`] and its rules: the comparisons
319/// are the same sentence in both, and only reading a value out of an argument
320/// and dividing by one differ. It is public because [`Bounds`] is, and there is
321/// no third kind to implement it for — `i64` and `f64` are what OpenAPI's
322/// `integer` and `number` are.
323pub trait Numeric: Copy + PartialOrd + fmt::Display {
324    /// What a refusal calls this kind.
325    const KIND: &'static str;
326
327    /// This kind read out of one argument, with the JSON it goes out as.
328    ///
329    /// One step, because the two can disagree: a float that parses but is not
330    /// finite is not a number JSON carries, and "is not a number" is the honest
331    /// answer rather than a `null` on the wire.
332    fn read(raw: &str) -> Option<(Self, serde_json::Value)>;
333
334    /// `multipleOf`: whether dividing by `step` leaves a whole number.
335    fn divisible_by(self, step: Self) -> bool;
336}
337
338impl Numeric for i64 {
339    const KIND: &'static str = "an integer";
340
341    fn read(raw: &str) -> Option<(Self, serde_json::Value)> {
342        raw.parse::<Self>().ok().map(|value| (value, value.into()))
343    }
344
345    fn divisible_by(self, step: Self) -> bool {
346        step != 0 && self % step == 0
347    }
348}
349
350impl Numeric for f64 {
351    const KIND: &'static str = "a number";
352
353    fn read(raw: &str) -> Option<(Self, serde_json::Value)> {
354        let value = raw.parse::<Self>().ok()?;
355        Some((value, serde_json::Number::from_f64(value)?.into()))
356    }
357
358    // `multipleOf` is exact division by definition, so the comparison is the
359    // document's rule rather than an approximation of it.
360    fn divisible_by(self, step: Self) -> bool {
361        step != 0.0 && (self / step).fract() == 0.0
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    fn text(pattern: &str) -> Scalar {
370        Scalar::Text(Text {
371            pattern: Some(pattern.to_owned()),
372            ..Text::default()
373        })
374    }
375
376    #[test]
377    fn a_pattern_is_enforced_and_reads_back_in_the_documents_own_spelling() {
378        let amount = text(r"^-?[0-9]+(\.[0-9]{1,2})?$");
379        for raw in ["12.50", "0", "0.0", "-3.07", "1000000.00"] {
380            assert_eq!(amount.parse(raw), Ok(raw.into()), "{raw}");
381        }
382        assert_eq!(
383            amount.parse("1,50"),
384            Err(ScalarError::Rule {
385                raw: "1,50".to_owned(),
386                rule: r"does not match ^-?[0-9]+(\.[0-9]{1,2})?$".to_owned(),
387            })
388        );
389        for raw in ["12.5x", "", "12.505", ".5", "-", "1e3"] {
390            assert!(amount.parse(raw).is_err(), "{raw}");
391        }
392    }
393
394    /// JSON Schema's `pattern` is a search. Anchoring it here would refuse
395    /// values the document allows, and a document that wants anchoring says so.
396    #[test]
397    fn an_unanchored_pattern_matches_anywhere_in_the_value() {
398        let digits = text("[0-9]+");
399        assert!(digits.parse("ab12cd").is_ok());
400        assert!(digits.parse("abcd").is_err());
401        assert!(text("^[A-Z]+$").parse("xyZ").is_err());
402    }
403
404    /// A rule nobody can run is not a rule everything passed.
405    #[test]
406    fn a_pattern_the_engine_cannot_read_refuses_every_value() {
407        let broken = text("[unterminated");
408        assert!(matches!(
409            broken.runnable(),
410            Err(ScalarError::Pattern { .. })
411        ));
412        assert!(matches!(
413            broken.parse("anything"),
414            Err(ScalarError::Pattern { .. })
415        ));
416        assert_eq!(text("^ok$").runnable(), Ok(()));
417    }
418
419    #[test]
420    fn lengths_are_counted_in_characters_and_refused_with_the_documents_number() {
421        let code = Scalar::Text(Text {
422            min_length: Some(3),
423            max_length: Some(3),
424            ..Text::default()
425        });
426        assert!(code.parse("EUR").is_ok());
427        // Three characters, nine bytes.
428        assert!(code.parse("€€€").is_ok());
429        assert_eq!(
430            code.parse("EU"),
431            Err(ScalarError::Rule {
432                raw: "EU".to_owned(),
433                rule: "is shorter than 3 characters".to_owned(),
434            })
435        );
436        assert_eq!(
437            code.parse("EURO"),
438            Err(ScalarError::Rule {
439                raw: "EURO".to_owned(),
440                rule: "is longer than 3 characters".to_owned(),
441            })
442        );
443    }
444
445    #[test]
446    fn an_inclusive_bound_admits_its_own_value_and_an_exclusive_one_does_not() {
447        let inclusive = Scalar::Integer(Bounds {
448            low: Some(Limit::Inclusive(1)),
449            high: Some(Limit::Inclusive(100)),
450            multiple_of: None,
451        });
452        assert!(inclusive.parse("1").is_ok());
453        assert!(inclusive.parse("100").is_ok());
454        assert_eq!(
455            inclusive.parse("0"),
456            Err(ScalarError::Rule {
457                raw: "0".to_owned(),
458                rule: "is not at least 1".to_owned(),
459            })
460        );
461        assert_eq!(
462            inclusive.parse("101"),
463            Err(ScalarError::Rule {
464                raw: "101".to_owned(),
465                rule: "is not at most 100".to_owned(),
466            })
467        );
468
469        let exclusive = Scalar::Number(Bounds {
470            low: Some(Limit::Exclusive(0.0)),
471            high: Some(Limit::Exclusive(1.0)),
472            multiple_of: None,
473        });
474        assert!(exclusive.parse("0.5").is_ok());
475        assert_eq!(
476            exclusive.parse("0"),
477            Err(ScalarError::Rule {
478                raw: "0".to_owned(),
479                rule: "is not more than 0".to_owned(),
480            })
481        );
482        assert_eq!(
483            exclusive.parse("1"),
484            Err(ScalarError::Rule {
485                raw: "1".to_owned(),
486                rule: "is not less than 1".to_owned(),
487            })
488        );
489    }
490
491    #[test]
492    fn a_step_is_enforced_for_both_number_kinds() {
493        let by_five = Scalar::Integer(Bounds {
494            multiple_of: Some(5),
495            ..Bounds::default()
496        });
497        assert!(by_five.parse("15").is_ok());
498        assert_eq!(
499            by_five.parse("7"),
500            Err(ScalarError::Rule {
501                raw: "7".to_owned(),
502                rule: "is not a multiple of 5".to_owned(),
503            })
504        );
505
506        let by_quarter = Scalar::Number(Bounds {
507            multiple_of: Some(0.25),
508            ..Bounds::default()
509        });
510        assert!(by_quarter.parse("1.75").is_ok());
511        assert!(by_quarter.parse("1.3").is_err());
512    }
513
514    #[test]
515    fn a_choice_outside_the_enum_is_rejected_with_the_alternatives() {
516        let status = Scalar::Choice(vec!["draft".to_owned(), "paid".to_owned()]);
517        assert_eq!(
518            status.parse("void"),
519            Err(ScalarError::Choice {
520                raw: "void".to_owned(),
521                allowed: vec!["draft".to_owned(), "paid".to_owned()],
522            })
523        );
524        assert!(status.parse("paid").is_ok());
525    }
526
527    #[test]
528    fn integers_stay_integers_in_json() {
529        let plain = Scalar::Integer(Bounds::default());
530        assert_eq!(plain.parse("5"), Ok(serde_json::json!(5)));
531        assert_eq!(
532            plain.parse("5.0"),
533            Err(ScalarError::Kind {
534                raw: "5.0".to_owned(),
535                wanted: "an integer",
536            })
537        );
538    }
539
540    /// A float JSON cannot carry is not a number, rather than a `null` sent in
541    /// place of one.
542    #[test]
543    fn a_number_json_cannot_carry_is_refused_rather_than_nulled() {
544        let plain = Scalar::Number(Bounds::default());
545        assert_eq!(plain.parse("1.5"), Ok(serde_json::json!(1.5)));
546        for raw in ["inf", "-inf", "NaN"] {
547            assert_eq!(
548                plain.parse(raw),
549                Err(ScalarError::Kind {
550                    raw: raw.to_owned(),
551                    wanted: "a number",
552                }),
553                "{raw}"
554            );
555        }
556    }
557
558    /// The help line and the refusal are one rendering, so what `--help`
559    /// advertises is the rule that runs.
560    #[test]
561    fn every_note_is_a_rule_that_is_enforced() {
562        let code = Scalar::Text(Text {
563            pattern: Some("^[A-Z]+$".to_owned()),
564            min_length: Some(2),
565            max_length: Some(4),
566        });
567        assert_eq!(
568            code.note().as_deref(),
569            Some("at least 2 characters; at most 4 characters; matches ^[A-Z]+$")
570        );
571        assert!(code.parse("A").is_err());
572        assert!(code.parse("ABCDE").is_err());
573        assert!(code.parse("ab").is_err());
574        assert!(code.parse("AB").is_ok());
575
576        let count = Scalar::Integer(Bounds {
577            low: Some(Limit::Inclusive(1)),
578            high: Some(Limit::Exclusive(10)),
579            multiple_of: Some(3),
580        });
581        assert_eq!(
582            count.note().as_deref(),
583            Some("at least 1; less than 10; a multiple of 3")
584        );
585        assert_eq!(Scalar::Boolean.note(), None);
586        assert_eq!(Scalar::Text(Text::default()).note(), None);
587    }
588}