Skip to main content

typed_openapi/
model.rs

1//! The document, reduced to the facts a CLI and a typed caller both need.
2//!
3//! `Document::load` takes the two files an adopter ships — the vendor's OpenAPI
4//! document and their Overlay of corrections — and hands back a list of
5//! [`Operation`]s with every `$ref` already resolved. Nothing from `openapiv3`
6//! escapes this module: after `load` returns, the parsed document is dropped.
7//!
8//! Reducing a document is bless-time work, not startup work, and the `document`
9//! feature is what says so in the manifest. A bless step enables it, calls
10//! `Document::load` once and [`Document::to_blob`] on the result; the binary it
11//! produces leaves it off, calls [`Document::from_blob`], and has no reader
12//! compiled into it to read YAML with. The two are the same reduction by
13//! construction — there is one `load` — and a test holds the shipped blob to
14//! the shipped document to prove the pair was written by the same run.
15
16use http::{Method, Uri};
17#[cfg(feature = "document")]
18use openapiv3::{
19    Components, OpenAPI, Parameter, ParameterSchemaOrContent, ReferenceOr, SchemaKind,
20};
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23
24use crate::names::{CommandName, renamed};
25#[cfg(feature = "document")]
26use crate::names::{Grouping, NameError, Namespace, kebab};
27use crate::scalar::Scalar;
28#[cfg(feature = "document")]
29use crate::schema::{RefError, is_json, is_multipart, resolve, resolve_schema, scalar_of};
30
31/// The three extensions this crate reads, all of them an adopter's say over
32/// something the document alone cannot settle. An Overlay is where they are
33/// written.
34///
35/// HTTP cannot say "this GET writes", so the document has to.
36#[cfg(feature = "document")]
37const WRITES: &str = "x-cli-writes";
38/// The command name to mount an operation under, where the path spells one
39/// badly — or where two operations reduce to the same name.
40#[cfg(feature = "document")]
41const COMMAND: &str = "x-cli-command";
42/// The group to mount an operation under, where the path's own segment is not
43/// the resource the operation belongs to.
44#[cfg(feature = "document")]
45const GROUP: &str = "x-cli-group";
46
47/// Whole-body flag, for every operation that takes JSON.
48pub const JSON_BODY: &str = "json-body";
49/// Whole-body flag, for a media type this CLI does not assemble.
50pub const RAW_BODY: &str = "raw-body";
51/// One file part of a multipart body.
52pub const FILE_PART: &str = "file";
53/// One text part of a multipart body.
54pub const FIELD_PART: &str = "field";
55/// The write gate.
56pub const COMMIT: &str = "commit";
57
58/// Every operation the document describes, in document order, plus the server
59/// it describes them against.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct Document {
62    #[serde(with = "uri_string")]
63    base: Uri,
64    ops: Vec<Operation>,
65}
66
67/// One operation: one subcommand under one group, one request.
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct Operation {
70    id: String,
71    group: CommandName,
72    command: CommandName,
73    #[serde(with = "method_string")]
74    method: Method,
75    path: String,
76    summary: Option<String>,
77    description: Option<String>,
78    params: Vec<Param>,
79    body: Body,
80    effect: Effect,
81}
82
83/// Whether the CLI must hold this operation behind `--commit`.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85pub enum Effect {
86    /// A safe method with no `x-cli-writes` marker: runs on sight.
87    Read,
88    /// A body-bearing or unsafe method, or a GET the document marks as writing.
89    Write,
90}
91
92/// Where a parameter goes in the request.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94pub enum Location {
95    Path,
96    Query,
97    Header,
98}
99
100/// One path, query, or header parameter.
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct Param {
103    name: String,
104    flag: String,
105    location: Location,
106    required: bool,
107    scalar: Scalar,
108    description: Option<String>,
109}
110
111/// One scalar property of a flat JSON body.
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct Field {
114    name: String,
115    flag: String,
116    required: bool,
117    scalar: Scalar,
118    description: Option<String>,
119}
120
121/// What the operation wants in the request body — and therefore which flags the
122/// subcommand grows. Each variant is exactly one flag set, so a body can never
123/// offer a flag that the request builder then ignores.
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub enum Body {
126    /// The document asks for no body.
127    None,
128    /// A JSON object whose every property is a scalar: one flag per property,
129    /// plus `--json-body` as a base document to merge them over.
130    JsonFields(Vec<Field>),
131    /// JSON this CLI will not take apart — a nested object, an array, anything
132    /// but an object of scalars. `--json-body` only, and no dead per-field
133    /// flags beside it.
134    JsonWhole { required: bool },
135    /// `multipart/form-data`: assembled from `--file name=@path` and
136    /// `--field name=value`. `names` is what the document declares, for the
137    /// help line; the CLI accepts any part name, because a document that
138    /// declares none (or misdeclares them) is common and refusing would help
139    /// nobody.
140    Multipart { names: Vec<String>, required: bool },
141    /// A media type this CLI does not assemble. `--raw-body FILE` sends the
142    /// bytes verbatim under this `Content-Type`.
143    Opaque { media_type: String, required: bool },
144}
145
146/// The bytes a bless step wrote are not a reduction this crate can read.
147///
148/// This is the whole of what [`Document::from_blob`] and [`Document::to_blob`]
149/// can say, and therefore the whole of what a shipped binary can fail with —
150/// the one door it has onto a document is the blob. The type has the same
151/// shape in every build: a caller who matches it exhaustively writes the same
152/// match whether or not the `document` feature is on, and reading its rustdoc
153/// under either feature set tells them the same thing. Reducing a document is
154/// a different job with a different failure list, and `LoadError` — which the
155/// `document` feature brings with the reader — is where that list lives.
156#[derive(Debug, Error)]
157pub enum DocumentError {
158    #[error("the reduced model the bless step writes is not valid: {0}")]
159    Blob(#[from] postcard::Error),
160}
161
162/// Reducing an OpenAPI document to [`Operation`]s failed.
163///
164/// *Requires the `document` feature.* Every variant names a way that reading a
165/// document goes wrong, so the whole type is bless-time: a binary that starts
166/// from [`Document::from_blob`] cannot produce one, does not compile one, and
167/// does not have to know the list exists.
168#[cfg(feature = "document")]
169#[derive(Debug, Error)]
170pub enum LoadError {
171    #[error(transparent)]
172    Overlay(#[from] crate::overlay::OverlayError),
173    #[error("the overlaid document is not an OpenAPI 3 document: {0}")]
174    Shape(#[source] serde_json::Error),
175    #[error("no `servers` entry to send requests to")]
176    NoServer,
177    #[error("`servers[0].url` ({url}) is not a URL: {source}")]
178    ServerUrl {
179        url: String,
180        #[source]
181        source: http::uri::InvalidUri,
182    },
183    #[error("{method} {path} has no operationId")]
184    NoOperationId { method: String, path: String },
185    #[error(transparent)]
186    Name(#[from] NameError),
187    #[error("{op}: `{key}` is not a string")]
188    Override { op: String, key: &'static str },
189    #[error(
190        "`{first}` and `{second}` are both `{group} {command}` on the command line; \
191         give one of them an `x-cli-command`"
192    )]
193    DuplicateCommand {
194        group: CommandName,
195        command: CommandName,
196        first: String,
197        second: String,
198    },
199    #[error(transparent)]
200    Reference(#[from] RefError),
201    #[error("{op}: parameter `{name}` is {reason}")]
202    Parameter {
203        op: String,
204        name: String,
205        reason: &'static str,
206    },
207    /// A rule the document states that cannot be run — a `pattern` no regex
208    /// engine here reads. Refused while the document is reduced, because a rule
209    /// that cannot run is one every value would otherwise pass.
210    #[error("{op}: `{name}`: {source}")]
211    Unrunnable {
212        op: String,
213        name: String,
214        #[source]
215        source: crate::scalar::ScalarError,
216    },
217}
218
219/// A generated inventory and the document it was generated from disagree.
220///
221/// Generated code names operations by position in the inventory it was emitted
222/// from. [`Document::matches`] is what makes that positional promise true for a
223/// document read at run time, and this is what it says when it is not.
224#[derive(Debug, Clone, Error, PartialEq, Eq)]
225#[error(
226    "the document and the generated inventory disagree at operation {position}: \
227     the inventory says `{expected}` and the document says {}",
228    found.as_deref().map_or_else(|| "there is no such operation".to_owned(), |id| format!("`{id}`"))
229)]
230pub struct DriftError {
231    pub position: usize,
232    pub expected: String,
233    pub found: Option<String>,
234}
235
236impl Document {
237    /// Parse the vendor's document, lay the adopter's Overlays over it in
238    /// order, and resolve the result into operations.
239    ///
240    /// *Requires the `document` feature.*
241    ///
242    /// Every argument is a file's contents, YAML or JSON. `overlays` is a list
243    /// because corrections come in layers — each one corrects the document the
244    /// ones before it produced, so the order they are given in is the order
245    /// they happen. An empty list runs a document that is already corrected.
246    ///
247    /// This is the expensive door, and the `document` feature is what opens it.
248    /// A bless step calls it once and writes [`Document::to_blob`] beside the
249    /// rest of what it generates; a shipped binary compiles without the feature
250    /// and reaches the same reduction through [`Document::from_blob`].
251    ///
252    /// ```
253    /// use typed_openapi::{Document, Invocation, Values};
254    ///
255    /// let doc = Document::load(
256    ///     include_str!("../tests/fixtures/toy.yaml"),
257    ///     &[
258    ///         include_str!("../tests/fixtures/corrections.yaml"),
259    ///         include_str!("../tests/fixtures/cli.yaml"),
260    ///     ],
261    /// )?;
262    /// let op = doc.get("getVoucher").expect("the document describes it");
263    /// let request = Invocation::new(op, Values::new().param("id", 5))?.request(doc.base())?;
264    /// assert_eq!(request.uri().path(), "/vouchers/5");
265    /// # Ok::<(), Box<dyn std::error::Error>>(())
266    /// ```
267    #[cfg(feature = "document")]
268    pub fn load(document: &str, overlays: &[&str]) -> Result<Self, LoadError> {
269        let mut doc = crate::overlay::parse(document)?;
270        for overlay in overlays {
271            doc = crate::overlay::apply(doc, overlay)?;
272        }
273        let doc: OpenAPI = serde_json::from_value(doc).map_err(LoadError::Shape)?;
274        Self::from_openapi(&doc)
275    }
276
277    /// The same reduction, already done and written down.
278    ///
279    /// This is the call a shipped binary makes. `Document::load` is bless-time
280    /// work — a YAML parse, an `openapiv3` deserialisation and a walk over every
281    /// path item — and none of it tells a CLI anything that is not already in
282    /// here. The bytes come from [`Document::to_blob`] in the same bless run
283    /// that wrote the rest of the generated code.
284    pub fn from_blob(blob: &[u8]) -> Result<Self, DocumentError> {
285        Ok(postcard::from_bytes(blob)?)
286    }
287
288    /// This reduction, as the bytes a bless step commits.
289    ///
290    /// The encoding is not self-describing and carries no version tag: it is
291    /// written and read by one build of one workspace, and an adopter who skips
292    /// the bless step is caught by the pairing check in `Api::new` and by the
293    /// test that reduces the committed document and compares it with this.
294    pub fn to_blob(&self) -> Result<Vec<u8>, DocumentError> {
295        Ok(postcard::to_allocvec(self)?)
296    }
297
298    #[cfg(feature = "document")]
299    fn from_openapi(doc: &OpenAPI) -> Result<Self, LoadError> {
300        let server = doc.servers.first().ok_or(LoadError::NoServer)?;
301        let base = Uri::try_from(&server.url).map_err(|source| LoadError::ServerUrl {
302            url: server.url.clone(),
303            source,
304        })?;
305        let empty = Components::default();
306        // Which segment groups this document is a fact about all of its paths,
307        // so it is read once and then applied one path at a time.
308        let paths: Vec<&str> = doc.paths.paths.keys().map(String::as_str).collect();
309        let whole = Reading {
310            components: doc.components.as_ref().unwrap_or(&empty),
311            grouping: Grouping::of(&paths),
312        };
313
314        let mut ops: Vec<Operation> = Vec::new();
315        for (path, item) in &doc.paths.paths {
316            let item = item.as_item().ok_or_else(|| RefError {
317                reference: format!("paths[{path}]"),
318            })?;
319            for (method, op) in item.iter() {
320                let params = item.parameters.iter().chain(op.parameters.iter());
321                ops.push(Operation::build(path, method, op, params, whole)?);
322            }
323        }
324        if let Some(collision) = first_collision(&ops) {
325            return Err(collision);
326        }
327        Ok(Self { base, ops })
328    }
329
330    /// The server the document names first. A caller may override it.
331    #[must_use]
332    pub fn base(&self) -> &Uri {
333        &self.base
334    }
335
336    pub fn iter(&self) -> std::slice::Iter<'_, Operation> {
337        self.ops.iter()
338    }
339
340    /// By `operationId`, as the document spells it. This is the lookup a typed
341    /// Rust caller uses.
342    #[must_use]
343    pub fn get(&self, operation_id: &str) -> Option<&Operation> {
344        self.ops.iter().find(|op| op.id == operation_id)
345    }
346
347    /// By the two names the user types, `<group> <command>`.
348    #[must_use]
349    pub fn by_command(&self, group: &str, command: &str) -> Option<&Operation> {
350        self.ops
351            .iter()
352            .find(|op| op.group.as_str() == group && op.command.as_str() == command)
353    }
354
355    /// Every operation, in document order. The order is the one a generated
356    /// inventory is emitted in, which is what [`Document::matches`] checks.
357    #[must_use]
358    pub fn operations(&self) -> &[Operation] {
359        &self.ops
360    }
361
362    /// Check this document against a generated `(operationId, method, path)`
363    /// inventory, row by row and in order.
364    ///
365    /// A caller that has run this may index [`Document::operations`] by the
366    /// inventory's own positions: every row named an operation, and every
367    /// operation was named by a row.
368    pub fn matches(&self, inventory: &[(&str, &str, &str)]) -> Result<(), DriftError> {
369        let drift = |position: usize, expected: &str| DriftError {
370            position,
371            expected: expected.to_owned(),
372            found: self.ops.get(position).map(|op| op.id.clone()),
373        };
374        for (position, (id, method, path)) in inventory.iter().enumerate() {
375            match self.ops.get(position) {
376                Some(op) if op.id == *id && op.method == *method && op.path == *path => {}
377                _ => return Err(drift(position, id)),
378            }
379        }
380        match self.ops.get(inventory.len()) {
381            None => Ok(()),
382            Some(extra) => Err(DriftError {
383                position: inventory.len(),
384                expected: "nothing after it".to_owned(),
385                found: Some(extra.id.clone()),
386            }),
387        }
388    }
389}
390
391impl<'a> IntoIterator for &'a Document {
392    type Item = &'a Operation;
393    type IntoIter = std::slice::Iter<'a, Operation>;
394
395    fn into_iter(self) -> Self::IntoIter {
396        self.ops.iter()
397    }
398}
399
400impl Operation {
401    #[cfg(feature = "document")]
402    fn build<'d>(
403        path: &str,
404        method: &str,
405        op: &openapiv3::Operation,
406        params: impl Iterator<Item = &'d ReferenceOr<Parameter>>,
407        whole: Reading<'_>,
408    ) -> Result<Self, LoadError> {
409        let id = op
410            .operation_id
411            .as_deref()
412            .ok_or_else(|| LoadError::NoOperationId {
413                method: method.to_owned(),
414                path: path.to_owned(),
415            })?;
416        let method = method_of(method);
417        let (group, command) = placement(op, id, path, &method, whole.grouping)?;
418
419        // One namespace per subcommand: the gate's own flags are claimed first.
420        let mut flags =
421            Namespace::with_reserved([COMMIT, JSON_BODY, RAW_BODY, FILE_PART, FIELD_PART]);
422        let params = params
423            .map(|p| Param::build(id, p, whole.components, &mut flags))
424            .collect::<Result<Vec<_>, _>>()?;
425        let body = Body::build(id, op, whole.components, &mut flags)?;
426        let effect = effect_of(&method, op);
427
428        Ok(Self {
429            id: id.to_owned(),
430            group,
431            command,
432            method,
433            path: path.to_owned(),
434            summary: op.summary.clone(),
435            description: op.description.clone(),
436            params,
437            body,
438            effect,
439        })
440    }
441
442    /// The `operationId`, as the document spells it.
443    #[must_use]
444    pub fn id(&self) -> &str {
445        &self.id
446    }
447
448    /// The group this operation is mounted under, as the user types it.
449    #[must_use]
450    pub fn group(&self) -> &CommandName {
451        &self.group
452    }
453
454    /// The subcommand name under that group, as the user types it.
455    #[must_use]
456    pub fn command(&self) -> &CommandName {
457        &self.command
458    }
459
460    #[must_use]
461    pub fn method(&self) -> &Method {
462        &self.method
463    }
464
465    /// The path template, `{name}` placeholders intact.
466    #[must_use]
467    pub fn path(&self) -> &str {
468        &self.path
469    }
470
471    #[must_use]
472    pub fn summary(&self) -> Option<&str> {
473        self.summary.as_deref()
474    }
475
476    #[must_use]
477    pub fn description(&self) -> Option<&str> {
478        self.description.as_deref()
479    }
480
481    #[must_use]
482    pub fn params(&self) -> &[Param] {
483        &self.params
484    }
485
486    #[must_use]
487    pub fn body(&self) -> &Body {
488        &self.body
489    }
490
491    #[must_use]
492    pub fn effect(&self) -> Effect {
493        self.effect
494    }
495
496    /// The parameter the document spells `name`, if there is one.
497    #[must_use]
498    pub fn param(&self, name: &str) -> Option<&Param> {
499        self.params.iter().find(|p| p.name == name)
500    }
501}
502
503/// What the whole document supplies while one of its operations is read: the
504/// schemas every `$ref` resolves against, and the rule that places operations
505/// in the command tree. Both are facts about the document rather than about
506/// the operation, so both are read once and handed down.
507#[cfg(feature = "document")]
508#[derive(Debug, Clone, Copy)]
509struct Reading<'d> {
510    components: &'d Components,
511    grouping: Grouping,
512}
513
514/// Where an operation sits in the command tree: the grouping rule, with the
515/// document's own overrides over it.
516///
517/// `x-cli-group` and `x-cli-command` are the adopter's say over a name a path
518/// spells badly, and the only way out of a collision — so they are read here,
519/// where the name is decided, and nowhere else.
520#[cfg(feature = "document")]
521fn placement(
522    op: &openapiv3::Operation,
523    id: &str,
524    path: &str,
525    method: &Method,
526    grouping: Grouping,
527) -> Result<(CommandName, CommandName), LoadError> {
528    let group = match named(op, id, GROUP)? {
529        Some(raw) => CommandName::new(GROUP, raw)?,
530        None => grouping.group(path)?,
531    };
532    let command = match named(op, id, COMMAND)? {
533        Some(raw) => CommandName::new(COMMAND, raw)?,
534        None => grouping.leaf(path, method)?,
535    };
536    Ok((group, command))
537}
538
539/// One `x-cli-` name the document offers, if it offers one.
540///
541/// A marker that is present and is not a string is the document saying
542/// something this crate has no reading for, and is refused rather than passed
543/// over — an adopter who writes a list where a name goes would otherwise get
544/// the name they were overriding.
545#[cfg(feature = "document")]
546fn named<'o>(
547    op: &'o openapiv3::Operation,
548    id: &str,
549    key: &'static str,
550) -> Result<Option<&'o str>, LoadError> {
551    match op.extensions.get(key) {
552        None => Ok(None),
553        Some(serde_json::Value::String(raw)) => Ok(Some(raw)),
554        Some(_) => Err(LoadError::Override {
555            op: id.to_owned(),
556            key,
557        }),
558    }
559}
560
561/// Two operations under one `<group> <command>` would silently shadow each
562/// other, so the document is refused instead — never resolved by renaming one
563/// of them, which would move a name nobody asked to move.
564#[cfg(feature = "document")]
565fn first_collision(ops: &[Operation]) -> Option<LoadError> {
566    ops.iter().enumerate().find_map(|(index, op)| {
567        let later = ops
568            .get(index + 1..)?
569            .iter()
570            .find(|later| later.group == op.group && later.command == op.command)?;
571        Some(LoadError::DuplicateCommand {
572            group: op.group.clone(),
573            command: op.command.clone(),
574            first: op.id.clone(),
575            second: later.id.clone(),
576        })
577    })
578}
579
580/// `PathItem::iter` yields only the eight methods OpenAPI names, lowercase.
581#[cfg(feature = "document")]
582fn method_of(name: &str) -> Method {
583    match name {
584        "put" => Method::PUT,
585        "post" => Method::POST,
586        "delete" => Method::DELETE,
587        "options" => Method::OPTIONS,
588        "head" => Method::HEAD,
589        "patch" => Method::PATCH,
590        "trace" => Method::TRACE,
591        _ => Method::GET,
592    }
593}
594
595/// A GET the document marks as writing is a write; so is anything but a safe
596/// method. Default-closed: the marker can only add writes, never remove them.
597#[cfg(feature = "document")]
598fn effect_of(method: &Method, op: &openapiv3::Operation) -> Effect {
599    if op.extensions.get(WRITES) == Some(&serde_json::Value::Bool(true)) {
600        return Effect::Write;
601    }
602    match *method {
603        Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE => Effect::Read,
604        _ => Effect::Write,
605    }
606}
607
608impl Param {
609    #[cfg(feature = "document")]
610    fn build(
611        op: &str,
612        param: &ReferenceOr<Parameter>,
613        components: &Components,
614        flags: &mut Namespace,
615    ) -> Result<Self, LoadError> {
616        let param = resolve(param, |key| components.parameters.get(key), "parameters")?;
617        let reject = |name: &str, reason: &'static str| LoadError::Parameter {
618            op: op.to_owned(),
619            name: name.to_owned(),
620            reason,
621        };
622        let (location, data) = match param {
623            Parameter::Path { parameter_data, .. } => (Location::Path, parameter_data),
624            Parameter::Query { parameter_data, .. } => (Location::Query, parameter_data),
625            Parameter::Header { parameter_data, .. } => (Location::Header, parameter_data),
626            Parameter::Cookie { parameter_data, .. } => {
627                return Err(reject(
628                    &parameter_data.name,
629                    "in: cookie, which this CLI does not send",
630                ));
631            }
632        };
633        let ParameterSchemaOrContent::Schema(schema) = &data.format else {
634            return Err(reject(
635                &data.name,
636                "described by `content`, which this CLI does not encode",
637            ));
638        };
639        let scalar = scalar_of(schema, components)?
640            .ok_or_else(|| reject(&data.name, "not a scalar, so it cannot be one flag"))?;
641        runnable(&scalar, op, &data.name)?;
642        Ok(Self {
643            flag: flags.claim(&kebab(&data.name), "param"),
644            name: data.name.clone(),
645            location,
646            required: data.required,
647            scalar,
648            description: data.description.clone(),
649        })
650    }
651
652    /// The wire name, as the document spells it.
653    #[must_use]
654    pub fn name(&self) -> &str {
655        &self.name
656    }
657
658    /// The flag name, without the leading `--`.
659    #[must_use]
660    pub fn flag(&self) -> &str {
661        &self.flag
662    }
663
664    /// The flag is not the plain kebab-case of the wire name, because that name
665    /// was already taken in this subcommand.
666    #[must_use]
667    pub fn renamed(&self) -> bool {
668        renamed(&self.flag, &self.name)
669    }
670
671    #[must_use]
672    pub fn location(&self) -> Location {
673        self.location
674    }
675
676    #[must_use]
677    pub fn required(&self) -> bool {
678        self.required
679    }
680
681    #[must_use]
682    pub fn scalar(&self) -> &Scalar {
683        &self.scalar
684    }
685
686    #[must_use]
687    pub fn description(&self) -> Option<&str> {
688        self.description.as_deref()
689    }
690}
691
692impl Field {
693    #[must_use]
694    pub fn name(&self) -> &str {
695        &self.name
696    }
697
698    #[must_use]
699    pub fn flag(&self) -> &str {
700        &self.flag
701    }
702
703    /// The flag is not the plain kebab-case of the wire name, because that name
704    /// was already taken in this subcommand.
705    #[must_use]
706    pub fn renamed(&self) -> bool {
707        renamed(&self.flag, &self.name)
708    }
709
710    #[must_use]
711    pub fn required(&self) -> bool {
712        self.required
713    }
714
715    #[must_use]
716    pub fn scalar(&self) -> &Scalar {
717        &self.scalar
718    }
719
720    #[must_use]
721    pub fn description(&self) -> Option<&str> {
722        self.description.as_deref()
723    }
724}
725
726impl Body {
727    #[cfg(feature = "document")]
728    fn build(
729        id: &str,
730        op: &openapiv3::Operation,
731        components: &Components,
732        flags: &mut Namespace,
733    ) -> Result<Self, LoadError> {
734        let Some(body) = &op.request_body else {
735            return Ok(Self::None);
736        };
737        let body = resolve(
738            body,
739            |key| components.request_bodies.get(key),
740            "requestBodies",
741        )?;
742        let required = body.required;
743        // The JSON entry if the document offers one, else whatever it offers
744        // first: a vendor who misspells `multipart/form-data` lands here.
745        let entry = body
746            .content
747            .iter()
748            .find(|(name, _)| is_json(name))
749            .or_else(|| body.content.iter().next());
750        let Some((media_type, media)) = entry else {
751            return Ok(Self::None);
752        };
753        if is_multipart(media_type) {
754            return Ok(Self::Multipart {
755                names: part_names(media, components),
756                required,
757            });
758        }
759        if !is_json(media_type) {
760            return Ok(Self::Opaque {
761                media_type: media_type.clone(),
762                required,
763            });
764        }
765        let Some(schema) = &media.schema else {
766            return Ok(Self::JsonWhole { required });
767        };
768        let schema = resolve_schema(schema, components)?;
769        let SchemaKind::Type(openapiv3::Type::Object(object)) = &schema.schema_kind else {
770            return Ok(Self::JsonWhole { required });
771        };
772
773        let mut fields = Vec::with_capacity(object.properties.len());
774        for (name, property) in &object.properties {
775            let property = property.clone().unbox();
776            let Some(scalar) = scalar_of(&property, components)? else {
777                // One nested property is enough: the whole body goes through
778                // `--json-body`, and no sibling gets a flag the request builder
779                // would then throw away.
780                return Ok(Self::JsonWhole { required });
781            };
782            runnable(&scalar, id, name)?;
783            let described = resolve_schema(&property, components)?;
784            fields.push(Field {
785                flag: flags.claim(&kebab(name), "body"),
786                name: name.clone(),
787                required: required && object.required.iter().any(|r| r == name),
788                scalar,
789                description: described.schema_data.description.clone(),
790            });
791        }
792        Ok(Self::JsonFields(fields))
793    }
794}
795
796/// Refuse a rule that cannot be run, naming the operation and the value it was
797/// stated about.
798///
799/// A `pattern` the engine cannot read refuses every value, so a document that
800/// states one describes a flag nothing can satisfy. Saying so while the
801/// document is reduced is what keeps that a bless-time failure rather than a
802/// user's.
803#[cfg(feature = "document")]
804fn runnable(scalar: &Scalar, op: &str, name: &str) -> Result<(), LoadError> {
805    scalar.runnable().map_err(|source| LoadError::Unrunnable {
806        op: op.to_owned(),
807        name: name.to_owned(),
808        source,
809    })
810}
811
812/// The part names a multipart schema declares, in document order.
813#[cfg(feature = "document")]
814fn part_names(media: &openapiv3::MediaType, components: &Components) -> Vec<String> {
815    let Some(schema) = &media.schema else {
816        return Vec::new();
817    };
818    let Ok(schema) = resolve_schema(schema, components) else {
819        return Vec::new();
820    };
821    let SchemaKind::Type(openapiv3::Type::Object(object)) = &schema.schema_kind else {
822        return Vec::new();
823    };
824    object.properties.keys().cloned().collect()
825}
826
827/// `http::Uri` is not a `serde` type; the blob carries the string it prints as.
828mod uri_string {
829    use http::Uri;
830    use serde::{Deserialize, Deserializer, Serializer};
831
832    pub(super) fn serialize<S: Serializer>(uri: &Uri, out: S) -> Result<S::Ok, S::Error> {
833        out.collect_str(uri)
834    }
835
836    pub(super) fn deserialize<'de, D: Deserializer<'de>>(input: D) -> Result<Uri, D::Error> {
837        let raw = String::deserialize(input)?;
838        raw.parse().map_err(serde::de::Error::custom)
839    }
840}
841
842/// `http::Method` is not a `serde` type either, and the eight OpenAPI methods
843/// are exactly the ones it spells as constants.
844mod method_string {
845    use http::Method;
846    use serde::{Deserialize, Deserializer, Serializer};
847
848    pub(super) fn serialize<S: Serializer>(method: &Method, out: S) -> Result<S::Ok, S::Error> {
849        out.serialize_str(method.as_str())
850    }
851
852    pub(super) fn deserialize<'de, D: Deserializer<'de>>(input: D) -> Result<Method, D::Error> {
853        let raw = String::deserialize(input)?;
854        raw.parse().map_err(serde::de::Error::custom)
855    }
856}