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, ParameterData, ParameterSchemaOrContent, PathStyle, QueryStyle,
20    ReferenceOr, Schema, SchemaKind,
21};
22use serde::{Deserialize, Serialize};
23use thiserror::Error;
24
25use crate::names::{CommandName, NameError, renamed, spelled};
26#[cfg(feature = "document")]
27use crate::names::{Grouping, Namespace, kebab};
28use crate::scalar::Scalar;
29#[cfg(feature = "document")]
30use crate::schema::{
31    RefError, description_of, is_json, is_media_type, is_multipart, resolve, resolve_schema,
32    scalar_of,
33};
34
35/// The four extensions this crate reads, all of them an adopter's say over
36/// something the document alone cannot settle. An Overlay is where they are
37/// written.
38///
39/// HTTP cannot say "this GET writes", so the document has to.
40#[cfg(feature = "document")]
41const WRITES: &str = "x-cli-writes";
42/// The command name to mount an operation under, where the path spells one
43/// badly — or where two operations reduce to the same name.
44#[cfg(feature = "document")]
45const COMMAND: &str = "x-cli-command";
46/// The group to mount an operation under, where the path's own segment is not
47/// the resource the operation belongs to.
48#[cfg(feature = "document")]
49const GROUP: &str = "x-cli-group";
50/// The hazards an operation stands behind by name, beside the write gate
51/// itself. One list rather than one marker per name, so a gate an adoption
52/// invents costs an Overlay line and not a release of this crate.
53#[cfg(feature = "document")]
54const GATES: &str = "x-cli-gates";
55
56/// Whole-body flag, for every operation that takes JSON.
57pub const JSON_BODY: &str = "json-body";
58/// Whole-body flag, for a media type this CLI does not assemble.
59pub const RAW_BODY: &str = "raw-body";
60/// One file part of a multipart body.
61pub const FILE_PART: &str = "file";
62/// One text part of a multipart body.
63pub const FIELD_PART: &str = "field";
64/// The write gate.
65pub const COMMIT: &str = "commit";
66
67/// The flags every subcommand spends before the document has a say: the write
68/// gate and the four body flags.
69///
70/// A parameter or a body field that wants one of these moves aside, and a gate
71/// that names one is refused — a gate is a word of the adopter's own, and these
72/// five words are already spoken for.
73#[cfg(feature = "document")]
74const RESERVED: [&str; 5] = [COMMIT, JSON_BODY, RAW_BODY, FILE_PART, FIELD_PART];
75
76/// Every operation the document describes, in document order, plus the server
77/// it describes them against.
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct Document {
80    #[serde(with = "uri_string")]
81    base: Uri,
82    ops: Vec<Operation>,
83}
84
85/// One operation: one subcommand under one group, one request.
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct Operation {
88    id: String,
89    group: CommandName,
90    command: CommandName,
91    #[serde(with = "method_string")]
92    method: Method,
93    path: String,
94    summary: Option<String>,
95    description: Option<String>,
96    params: Vec<Param>,
97    body: Body,
98    effect: Effect,
99    gates: Vec<Gate>,
100}
101
102/// Whether the CLI must hold this operation behind `--commit`.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104pub enum Effect {
105    /// A safe method with no `x-cli-writes` marker: runs on sight.
106    Read,
107    /// A body-bearing or unsafe method, or a GET the document marks as writing.
108    Write,
109}
110
111/// One named hazard an operation stands behind, spelled as a long flag.
112///
113/// `--commit` asks one question — did you mean to write? — and some operations
114/// are more than one question: an act that cannot be undone, and an act that
115/// reaches a third party, each want their own word rather than a second meaning
116/// for that one. A gate is answered *beside* the confirmation, never instead of
117/// it, so what a gate adds is always another thing to say and never permission
118/// to say less. What the word means is the adopter's business and no business
119/// of this crate's, which only carries it.
120///
121/// A gate travels in the reduced model, so a name comes back off a blob as well
122/// as out of a document. Both doors are the same door: `serde` reads it as a
123/// `String` and runs it through the spelling rule a command name passes, so a
124/// blob cannot smuggle in a flag a document could not have asked for.
125#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
126#[serde(try_from = "String", into = "String")]
127pub struct Gate(String);
128
129impl Gate {
130    /// `sendEmail` becomes `send-email`; anything that will not reduce to
131    /// `[a-z0-9-]` is rejected rather than mangled, because what is being
132    /// spelled is a flag a user has to type.
133    pub fn new(origin: &'static str, raw: &str) -> Result<Self, NameError> {
134        spelled(origin, raw).map(Self)
135    }
136
137    /// The flag name, without the leading `--`.
138    #[must_use]
139    pub fn as_str(&self) -> &str {
140        &self.0
141    }
142}
143
144impl TryFrom<String> for Gate {
145    type Error = NameError;
146
147    fn try_from(raw: String) -> Result<Self, NameError> {
148        Self::new("reduced model", &raw)
149    }
150}
151
152impl From<Gate> for String {
153    fn from(gate: Gate) -> Self {
154        gate.0
155    }
156}
157
158impl std::fmt::Display for Gate {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        f.write_str(&self.0)
161    }
162}
163
164/// Where a parameter goes in the request.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166pub enum Location {
167    Path,
168    Query,
169    Header,
170}
171
172/// One parameter the document declares.
173///
174/// Everything that only a parameter with a flag has — the flag, where its value
175/// goes, what the flag accepts — hangs off [`Shape`], because a parameter this
176/// CLI cannot spell has none of it.
177#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
178pub struct Param {
179    name: String,
180    required: bool,
181    shape: Shape,
182    description: Option<String>,
183}
184
185/// What one parameter is worth on a command line.
186///
187/// Either the subcommand grows a flag and the request builder knows what to do
188/// with its values, or it grows nothing at all. Everything a flag needs lives on
189/// the variant that has one, so a parameter this CLI cannot supply cannot leave
190/// a flag behind that the request builder would then ignore — which is the shape
191/// [`Body`] already has, where one nested property sends a whole body through
192/// `--json-body` rather than offering dead per-field flags beside it.
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
194pub enum Shape {
195    /// The flag this parameter grows, where its value goes, and what the flag
196    /// accepts. `join` is `None` for a parameter that takes one value, and
197    /// `Some` for a list — a flag that may be given again.
198    Flag {
199        flag: String,
200        location: Location,
201        scalar: Scalar,
202        join: Option<Join>,
203    },
204    /// Nothing a flag carries. The subcommand names the parameter in its long
205    /// help and grows nothing for it, and the request goes out without it.
206    Unreachable(Unsupported),
207}
208
209/// How the values of a list parameter reach the request.
210///
211/// Read off the `style` and `explode` the parameter declares, and obeyed by the
212/// request builder and by the flag's help line alike — so what `--help` says a
213/// repeated flag does is what it does.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
215pub enum Join {
216    /// One field per value: `?embed=a&embed=b`. A query parameter's
217    /// `style: form` with `explode: true`, which is what OpenAPI defaults a
218    /// query parameter to.
219    Pairs,
220    /// One field, values separated by commas: `?embed=a,b`, and `a,b` in a path
221    /// segment or a header. A query parameter's `style: form` with
222    /// `explode: false`, and `style: simple` everywhere else.
223    Commas,
224}
225
226/// Why a parameter carries no flag.
227///
228/// Four shapes, one answer, because they cost a document the same thing: an
229/// operation nobody can express sits beside a hundred that are expressible, and
230/// refusing the document for it makes those hundred unreachable too. So an
231/// unsupported parameter is carried rather than refused, and only a *required*
232/// one — an operation that could never be invoked correctly — is named as a
233/// `LoadError` while the document is reduced.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub enum Unsupported {
236    /// `in: cookie`. This CLI sends no cookies.
237    Cookie,
238    /// Described by `content` rather than `schema`: what the document asks for
239    /// is a document in some media type, not a value.
240    Encoded,
241    /// A schema that is neither a value nor a list of values — an object, or an
242    /// array of them.
243    ///
244    /// There is no spelling here to be exact about. OpenAPI states how a *flat*
245    /// object serialises under `deepObject` and states nothing at all for a
246    /// nested one; under the `form` a query parameter defaults to, an object's
247    /// properties become top-level fields that collide with the operation's own
248    /// parameters. A rendering this crate invented would produce a request that
249    /// looks sent and is not read, which is worse than one that was never built.
250    Structured,
251    /// A `style` this crate does not serialise, named as the document spells
252    /// it. Writing it out as some other style would put the value on the wire
253    /// in a shape the server does not read.
254    Style(String),
255}
256
257impl std::fmt::Display for Unsupported {
258    /// The sentence a refusal and a subcommand's long help both use, so that
259    /// what a user is told about a missing flag is what a bless step was told.
260    fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        match self {
262            Self::Cookie => out.write_str("`in: cookie`, which this CLI does not send"),
263            Self::Encoded => {
264                out.write_str("described by `content`, which this CLI does not encode")
265            }
266            Self::Structured => out.write_str("neither a value nor a list of values"),
267            Self::Style(style) => {
268                write!(
269                    out,
270                    "declared with `style: {style}`, which this CLI does not serialise"
271                )
272            }
273        }
274    }
275}
276
277impl Join {
278    /// What a repeated flag does, for its help line. The same fact the request
279    /// builder obeys, written once.
280    #[must_use]
281    pub fn note(self) -> &'static str {
282        match self {
283            Self::Pairs => "repeatable; each value is sent as its own field",
284            Self::Commas => "repeatable; the values are sent comma-separated in one field",
285        }
286    }
287}
288
289impl Shape {
290    /// Whether this parameter takes more than one value: a flag that may be
291    /// given again, and a list on the wire.
292    #[must_use]
293    pub fn repeatable(&self) -> bool {
294        matches!(self, Self::Flag { join: Some(_), .. })
295    }
296}
297
298/// One scalar property of a flat JSON body.
299#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
300pub struct Field {
301    name: String,
302    flag: String,
303    required: bool,
304    scalar: Scalar,
305    description: Option<String>,
306}
307
308/// What the operation wants in the request body — and therefore which flags the
309/// subcommand grows. Each variant is exactly one flag set, so a body can never
310/// offer a flag that the request builder then ignores.
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
312pub enum Body {
313    /// The document asks for no body.
314    None,
315    /// A JSON object whose every property is a scalar: one flag per property,
316    /// plus `--json-body` as a base document to merge them over.
317    JsonFields(Vec<Field>),
318    /// JSON this CLI will not take apart — a nested object, an array, anything
319    /// but an object of scalars. `--json-body` only, and no dead per-field
320    /// flags beside it.
321    JsonWhole { required: bool },
322    /// `multipart/form-data`: assembled from `--file name=@path` and
323    /// `--field name=value`. `names` is what the document declares, for the
324    /// help line; the CLI accepts any part name, because a document that
325    /// declares none (or misdeclares them) is common and refusing would help
326    /// nobody.
327    Multipart { names: Vec<String>, required: bool },
328    /// A media type this CLI does not assemble. `--raw-body FILE` sends the
329    /// bytes verbatim under this `Content-Type` — and a media type is what it
330    /// is, because a `content` key that is not one is refused while the
331    /// document is reduced rather than carried here.
332    Opaque { media_type: String, required: bool },
333}
334
335/// The bytes a bless step wrote are not a reduction this crate can read.
336///
337/// This is the whole of what [`Document::from_blob`] and [`Document::to_blob`]
338/// can say, and therefore the whole of what a shipped binary can fail with —
339/// the one door it has onto a document is the blob. The type has the same
340/// shape in every build: a caller who matches it exhaustively writes the same
341/// match whether or not the `document` feature is on, and reading its rustdoc
342/// under either feature set tells them the same thing. Reducing a document is
343/// a different job with a different failure list, and `LoadError` — which the
344/// `document` feature brings with the reader — is where that list lives.
345#[derive(Debug, Error)]
346pub enum DocumentError {
347    #[error("the reduced model the bless step writes is not valid: {0}")]
348    Blob(#[from] postcard::Error),
349}
350
351/// Reducing an OpenAPI document to [`Operation`]s failed.
352///
353/// *Requires the `document` feature.* Every variant names a way that reading a
354/// document goes wrong, so the whole type is bless-time: a binary that starts
355/// from [`Document::from_blob`] cannot produce one, does not compile one, and
356/// does not have to know the list exists.
357#[cfg(feature = "document")]
358#[derive(Debug, Error)]
359pub enum LoadError {
360    #[error(transparent)]
361    Overlay(#[from] crate::overlay::OverlayError),
362    #[error("the overlaid document is not an OpenAPI 3 document: {0}")]
363    Shape(#[source] serde_json::Error),
364    #[error("no `servers` entry to send requests to")]
365    NoServer,
366    #[error("`servers[0].url` ({url}) is not a URL: {source}")]
367    ServerUrl {
368        url: String,
369        #[source]
370        source: http::uri::InvalidUri,
371    },
372    #[error("{method} {path} has no operationId")]
373    NoOperationId { method: String, path: String },
374    #[error(transparent)]
375    Name(#[from] NameError),
376    #[error("{op}: `{key}` is not a string")]
377    Override { op: String, key: &'static str },
378    #[error(
379        "`{first}` and `{second}` are both `{group} {command}` on the command line; \
380         give one of them an `x-cli-command`"
381    )]
382    DuplicateCommand {
383        group: CommandName,
384        command: CommandName,
385        first: String,
386        second: String,
387    },
388    #[error("{op}: `{key}` is not a list of names")]
389    GateList { op: String, key: &'static str },
390    #[error("{op}: the gate `{gate}` is one of the flags every subcommand already spends")]
391    ReservedGate { op: String, gate: Gate },
392    #[error("{op}: the gate `{gate}` is named twice")]
393    DuplicateGate { op: String, gate: Gate },
394    /// A read that names a gate is a document saying two things at once: a read
395    /// is sent on sight, so there is nothing for the gate to hold back. Refused
396    /// rather than mounted, because one of the two statements is a mistake and
397    /// the document does not say which.
398    #[error(
399        "{op}: a read stands behind no gate, and this one names `{gate}`; \
400         mark the operation `x-cli-writes: true` or drop the gate"
401    )]
402    GatedRead { op: String, gate: Gate },
403    #[error(transparent)]
404    Reference(#[from] RefError),
405    /// A parameter this CLI cannot supply that the document says a caller must.
406    ///
407    /// Every other unsupported parameter is carried as [`Shape::Unreachable`]
408    /// and costs the document nothing. This one is an operation that could never
409    /// be invoked correctly, so it is named here rather than mounted as a
410    /// subcommand that is guaranteed to build a request the server refuses.
411    #[error(
412        "{op}: parameter `{name}` is {why}, and the document requires it; \
413         correct the parameter in an Overlay, or drop its `required`"
414    )]
415    Parameter {
416        op: String,
417        name: String,
418        why: Unsupported,
419    },
420    /// A `requestBody` whose `content` key names no media type — `form-data`
421    /// where `multipart/form-data` was meant. Refused while the document is
422    /// reduced, because the alternative is a request sent under a
423    /// `Content-Type` no server can read.
424    #[error(
425        "{op}: `{media_type}` is not a media type; \
426         an Overlay is where a document's content type is corrected"
427    )]
428    MediaType { op: String, media_type: String },
429    /// A rule the document states that cannot be run — a `pattern` no regex
430    /// engine here reads. Refused while the document is reduced, because a rule
431    /// that cannot run is one every value would otherwise pass.
432    #[error("{op}: `{name}`: {source}")]
433    Unrunnable {
434        op: String,
435        name: String,
436        #[source]
437        source: crate::scalar::ScalarError,
438    },
439}
440
441/// A generated inventory and the document it was generated from disagree.
442///
443/// Generated code names operations by position in the inventory it was emitted
444/// from. [`Document::matches`] is what makes that positional promise true for a
445/// document read at run time, and this is what it says when it is not.
446#[derive(Debug, Clone, Error, PartialEq, Eq)]
447#[error(
448    "the document and the generated inventory disagree at operation {position}: \
449     the inventory says `{expected}` and the document says {}",
450    found.as_deref().map_or_else(|| "there is no such operation".to_owned(), |id| format!("`{id}`"))
451)]
452pub struct DriftError {
453    pub position: usize,
454    pub expected: String,
455    pub found: Option<String>,
456}
457
458impl Document {
459    /// Parse the vendor's document, lay the adopter's Overlays over it in
460    /// order, and resolve the result into operations.
461    ///
462    /// *Requires the `document` feature.*
463    ///
464    /// Every argument is a file's contents, YAML or JSON. `overlays` is a list
465    /// because corrections come in layers — each one corrects the document the
466    /// ones before it produced, so the order they are given in is the order
467    /// they happen. An empty list runs a document that is already corrected.
468    ///
469    /// This is the expensive door, and the `document` feature is what opens it.
470    /// A bless step calls it once and writes [`Document::to_blob`] beside the
471    /// rest of what it generates; a shipped binary compiles without the feature
472    /// and reaches the same reduction through [`Document::from_blob`].
473    ///
474    /// ```
475    /// use typed_openapi::{Document, Invocation, Values};
476    ///
477    /// let doc = Document::load(
478    ///     include_str!("../tests/fixtures/toy.yaml"),
479    ///     &[
480    ///         include_str!("../tests/fixtures/corrections.yaml"),
481    ///         include_str!("../tests/fixtures/cli.yaml"),
482    ///     ],
483    /// )?;
484    /// let op = doc.get("getVoucher").expect("the document describes it");
485    /// let request = Invocation::new(op, Values::new().param("id", 5))?.request(doc.base())?;
486    /// assert_eq!(request.uri().path(), "/vouchers/5");
487    /// # Ok::<(), Box<dyn std::error::Error>>(())
488    /// ```
489    #[cfg(feature = "document")]
490    pub fn load(document: &str, overlays: &[&str]) -> Result<Self, LoadError> {
491        let mut doc = crate::overlay::parse(document)?;
492        for overlay in overlays {
493            doc = crate::overlay::apply(doc, overlay)?;
494        }
495        let doc: OpenAPI = serde_json::from_value(doc).map_err(LoadError::Shape)?;
496        Self::from_openapi(&doc)
497    }
498
499    /// The same reduction, already done and written down.
500    ///
501    /// This is the call a shipped binary makes. `Document::load` is bless-time
502    /// work — a YAML parse, an `openapiv3` deserialisation and a walk over every
503    /// path item — and none of it tells a CLI anything that is not already in
504    /// here. The bytes come from [`Document::to_blob`] in the same bless run
505    /// that wrote the rest of the generated code.
506    pub fn from_blob(blob: &[u8]) -> Result<Self, DocumentError> {
507        Ok(postcard::from_bytes(blob)?)
508    }
509
510    /// This reduction, as the bytes a bless step commits.
511    ///
512    /// The encoding is not self-describing and carries no version tag: it is
513    /// written and read by one build of one workspace, and an adopter who skips
514    /// the bless step is caught by the pairing check in `Api::new` and by the
515    /// test that reduces the committed document and compares it with this.
516    pub fn to_blob(&self) -> Result<Vec<u8>, DocumentError> {
517        Ok(postcard::to_allocvec(self)?)
518    }
519
520    #[cfg(feature = "document")]
521    fn from_openapi(doc: &OpenAPI) -> Result<Self, LoadError> {
522        let server = doc.servers.first().ok_or(LoadError::NoServer)?;
523        let base = Uri::try_from(&server.url).map_err(|source| LoadError::ServerUrl {
524            url: server.url.clone(),
525            source,
526        })?;
527        let empty = Components::default();
528        // Which segment groups this document is a fact about all of its paths,
529        // so it is read once and then applied one path at a time.
530        let paths: Vec<&str> = doc.paths.paths.keys().map(String::as_str).collect();
531        let whole = Reading {
532            components: doc.components.as_ref().unwrap_or(&empty),
533            grouping: Grouping::of(&paths),
534        };
535
536        let mut ops: Vec<Operation> = Vec::new();
537        for (path, item) in &doc.paths.paths {
538            let item = item.as_item().ok_or_else(|| RefError {
539                reference: format!("paths[{path}]"),
540            })?;
541            for (method, op) in item.iter() {
542                let params = item.parameters.iter().chain(op.parameters.iter());
543                ops.push(Operation::build(path, method, op, params, whole)?);
544            }
545        }
546        if let Some(collision) = first_collision(&ops) {
547            return Err(collision);
548        }
549        Ok(Self { base, ops })
550    }
551
552    /// The server the document names first. A caller may override it.
553    #[must_use]
554    pub fn base(&self) -> &Uri {
555        &self.base
556    }
557
558    pub fn iter(&self) -> std::slice::Iter<'_, Operation> {
559        self.ops.iter()
560    }
561
562    /// By `operationId`, as the document spells it. This is the lookup a typed
563    /// Rust caller uses.
564    #[must_use]
565    pub fn get(&self, operation_id: &str) -> Option<&Operation> {
566        self.ops.iter().find(|op| op.id == operation_id)
567    }
568
569    /// By the two names the user types, `<group> <command>`.
570    #[must_use]
571    pub fn by_command(&self, group: &str, command: &str) -> Option<&Operation> {
572        self.ops
573            .iter()
574            .find(|op| op.group.as_str() == group && op.command.as_str() == command)
575    }
576
577    /// Every operation, in document order. The order is the one a generated
578    /// inventory is emitted in, which is what [`Document::matches`] checks.
579    #[must_use]
580    pub fn operations(&self) -> &[Operation] {
581        &self.ops
582    }
583
584    /// Every gate any operation in this document names, once each and in
585    /// document order.
586    ///
587    /// This is the list a `--help` page, a release note or a test suite reads
588    /// instead of keeping one by hand: a gate an Overlay adds appears here the
589    /// moment the document is reduced, and nothing has to be told twice.
590    #[must_use]
591    pub fn gates(&self) -> Vec<&Gate> {
592        let mut named: Vec<&Gate> = Vec::new();
593        for gate in self.ops.iter().flat_map(Operation::gates) {
594            if !named.contains(&gate) {
595                named.push(gate);
596            }
597        }
598        named
599    }
600
601    /// Every operation standing behind one gate, in document order.
602    ///
603    /// A suite that has something to say about everything irreversible asks
604    /// the document which operations those are, rather than carrying a list
605    /// that an Overlay can silently grow past.
606    pub fn gated_by(&self, gate: &str) -> impl Iterator<Item = &Operation> {
607        self.ops
608            .iter()
609            .filter(move |op| op.gates().iter().any(|named| named.as_str() == gate))
610    }
611
612    /// Check this document against a generated `(operationId, method, path)`
613    /// inventory, row by row and in order.
614    ///
615    /// A caller that has run this may index [`Document::operations`] by the
616    /// inventory's own positions: every row named an operation, and every
617    /// operation was named by a row.
618    pub fn matches(&self, inventory: &[(&str, &str, &str)]) -> Result<(), DriftError> {
619        let drift = |position: usize, expected: &str| DriftError {
620            position,
621            expected: expected.to_owned(),
622            found: self.ops.get(position).map(|op| op.id.clone()),
623        };
624        for (position, (id, method, path)) in inventory.iter().enumerate() {
625            match self.ops.get(position) {
626                Some(op) if op.id == *id && op.method == *method && op.path == *path => {}
627                _ => return Err(drift(position, id)),
628            }
629        }
630        match self.ops.get(inventory.len()) {
631            None => Ok(()),
632            Some(extra) => Err(DriftError {
633                position: inventory.len(),
634                expected: "nothing after it".to_owned(),
635                found: Some(extra.id.clone()),
636            }),
637        }
638    }
639}
640
641impl<'a> IntoIterator for &'a Document {
642    type Item = &'a Operation;
643    type IntoIter = std::slice::Iter<'a, Operation>;
644
645    fn into_iter(self) -> Self::IntoIter {
646        self.ops.iter()
647    }
648}
649
650impl Operation {
651    #[cfg(feature = "document")]
652    fn build<'d>(
653        path: &str,
654        method: &str,
655        op: &openapiv3::Operation,
656        params: impl Iterator<Item = &'d ReferenceOr<Parameter>>,
657        whole: Reading<'_>,
658    ) -> Result<Self, LoadError> {
659        let id = op
660            .operation_id
661            .as_deref()
662            .ok_or_else(|| LoadError::NoOperationId {
663                method: method.to_owned(),
664                path: path.to_owned(),
665            })?;
666        let method = method_of(method);
667        let (group, command) = placement(op, id, path, &method, whole.grouping)?;
668        let effect = effect_of(&method, op);
669        // The gates are read before the namespace exists, because their flags
670        // belong in it: a body field the vendor happens to spell `enshrine`
671        // moves aside rather than shadowing the word that stands in front of
672        // the hazard.
673        let gates = gates_of(op, id, effect)?;
674
675        // One namespace per subcommand: the CLI's own flags — this operation's
676        // gates, the confirmation and the body flags — are spent first.
677        let mut flags = Namespace::with_reserved(gates.iter().map(Gate::as_str).chain(RESERVED));
678        let params = params
679            .map(|p| Param::build(id, p, whole.components, &mut flags))
680            .collect::<Result<Vec<_>, _>>()?;
681        let body = Body::build(id, op, whole.components, &mut flags)?;
682
683        Ok(Self {
684            id: id.to_owned(),
685            group,
686            command,
687            method,
688            path: path.to_owned(),
689            summary: op.summary.clone(),
690            description: op.description.clone(),
691            params,
692            body,
693            effect,
694            gates,
695        })
696    }
697
698    /// The `operationId`, as the document spells it.
699    #[must_use]
700    pub fn id(&self) -> &str {
701        &self.id
702    }
703
704    /// The group this operation is mounted under, as the user types it.
705    #[must_use]
706    pub fn group(&self) -> &CommandName {
707        &self.group
708    }
709
710    /// The subcommand name under that group, as the user types it.
711    #[must_use]
712    pub fn command(&self) -> &CommandName {
713        &self.command
714    }
715
716    #[must_use]
717    pub fn method(&self) -> &Method {
718        &self.method
719    }
720
721    /// The path template, `{name}` placeholders intact.
722    #[must_use]
723    pub fn path(&self) -> &str {
724        &self.path
725    }
726
727    #[must_use]
728    pub fn summary(&self) -> Option<&str> {
729        self.summary.as_deref()
730    }
731
732    #[must_use]
733    pub fn description(&self) -> Option<&str> {
734        self.description.as_deref()
735    }
736
737    #[must_use]
738    pub fn params(&self) -> &[Param] {
739        &self.params
740    }
741
742    #[must_use]
743    pub fn body(&self) -> &Body {
744        &self.body
745    }
746
747    #[must_use]
748    pub fn effect(&self) -> Effect {
749        self.effect
750    }
751
752    /// The named hazards this operation stands behind, in the order the
753    /// document names them. Every one of them is answered beside the write
754    /// confirmation, and a read has none.
755    #[must_use]
756    pub fn gates(&self) -> &[Gate] {
757        &self.gates
758    }
759
760    /// The parameter the document spells `name`, if there is one.
761    #[must_use]
762    pub fn param(&self, name: &str) -> Option<&Param> {
763        self.params.iter().find(|p| p.name == name)
764    }
765}
766
767/// What the whole document supplies while one of its operations is read: the
768/// schemas every `$ref` resolves against, and the rule that places operations
769/// in the command tree. Both are facts about the document rather than about
770/// the operation, so both are read once and handed down.
771#[cfg(feature = "document")]
772#[derive(Debug, Clone, Copy)]
773struct Reading<'d> {
774    components: &'d Components,
775    grouping: Grouping,
776}
777
778/// Where an operation sits in the command tree: the grouping rule, with the
779/// document's own overrides over it.
780///
781/// `x-cli-group` and `x-cli-command` are the adopter's say over a name a path
782/// spells badly, and the only way out of a collision — so they are read here,
783/// where the name is decided, and nowhere else.
784#[cfg(feature = "document")]
785fn placement(
786    op: &openapiv3::Operation,
787    id: &str,
788    path: &str,
789    method: &Method,
790    grouping: Grouping,
791) -> Result<(CommandName, CommandName), LoadError> {
792    let group = match named(op, id, GROUP)? {
793        Some(raw) => CommandName::new(GROUP, raw)?,
794        None => grouping.group(path)?,
795    };
796    let command = match named(op, id, COMMAND)? {
797        Some(raw) => CommandName::new(COMMAND, raw)?,
798        None => grouping.leaf(path, method)?,
799    };
800    Ok((group, command))
801}
802
803/// One `x-cli-` name the document offers, if it offers one.
804///
805/// A marker that is present and is not a string is the document saying
806/// something this crate has no reading for, and is refused rather than passed
807/// over — an adopter who writes a list where a name goes would otherwise get
808/// the name they were overriding.
809#[cfg(feature = "document")]
810fn named<'o>(
811    op: &'o openapiv3::Operation,
812    id: &str,
813    key: &'static str,
814) -> Result<Option<&'o str>, LoadError> {
815    match op.extensions.get(key) {
816        None => Ok(None),
817        Some(serde_json::Value::String(raw)) => Ok(Some(raw)),
818        Some(_) => Err(LoadError::Override {
819            op: id.to_owned(),
820            key,
821        }),
822    }
823}
824
825/// The gates one operation stands behind, in the order the document names them.
826///
827/// Every rule about a gate is run here, while the document is reduced: a name
828/// that is not a flag, a name the command line has already spent, a name given
829/// twice, and a gate on an operation that is sent on sight. All four are an
830/// adopter's mistake, and all four are refused at their expense rather than at
831/// a user's — a gate that reaches a shipped binary is a gate somebody is about
832/// to type.
833#[cfg(feature = "document")]
834fn gates_of(op: &openapiv3::Operation, id: &str, effect: Effect) -> Result<Vec<Gate>, LoadError> {
835    let mut gates: Vec<Gate> = Vec::new();
836    for raw in listed(op, id, GATES)? {
837        let gate = Gate::new(GATES, raw)?;
838        if RESERVED.contains(&gate.as_str()) {
839            return Err(LoadError::ReservedGate {
840                op: id.to_owned(),
841                gate,
842            });
843        }
844        if gates.contains(&gate) {
845            return Err(LoadError::DuplicateGate {
846                op: id.to_owned(),
847                gate,
848            });
849        }
850        gates.push(gate);
851    }
852    match (effect, gates.first()) {
853        (Effect::Read, Some(gate)) => Err(LoadError::GatedRead {
854            op: id.to_owned(),
855            gate: gate.clone(),
856        }),
857        _ => Ok(gates),
858    }
859}
860
861/// The names one list-valued `x-cli-` marker offers, if it offers any.
862///
863/// A marker that is present and is not a list of names is the document saying
864/// something this crate has no reading for, and is refused rather than passed
865/// over — the same reading [`named`] gives a marker that should have been one
866/// name, for the same reason: an adopter who writes one word where a list goes
867/// would otherwise get no gate at all.
868#[cfg(feature = "document")]
869fn listed<'o>(
870    op: &'o openapiv3::Operation,
871    id: &str,
872    key: &'static str,
873) -> Result<Vec<&'o str>, LoadError> {
874    let reject = || LoadError::GateList {
875        op: id.to_owned(),
876        key,
877    };
878    match op.extensions.get(key) {
879        None => Ok(Vec::new()),
880        Some(serde_json::Value::Array(names)) => names
881            .iter()
882            .map(|name| name.as_str().ok_or_else(reject))
883            .collect(),
884        Some(_) => Err(reject()),
885    }
886}
887
888/// Two operations under one `<group> <command>` would silently shadow each
889/// other, so the document is refused instead — never resolved by renaming one
890/// of them, which would move a name nobody asked to move.
891#[cfg(feature = "document")]
892fn first_collision(ops: &[Operation]) -> Option<LoadError> {
893    ops.iter().enumerate().find_map(|(index, op)| {
894        let later = ops
895            .get(index + 1..)?
896            .iter()
897            .find(|later| later.group == op.group && later.command == op.command)?;
898        Some(LoadError::DuplicateCommand {
899            group: op.group.clone(),
900            command: op.command.clone(),
901            first: op.id.clone(),
902            second: later.id.clone(),
903        })
904    })
905}
906
907/// `PathItem::iter` yields only the eight methods OpenAPI names, lowercase.
908#[cfg(feature = "document")]
909fn method_of(name: &str) -> Method {
910    match name {
911        "put" => Method::PUT,
912        "post" => Method::POST,
913        "delete" => Method::DELETE,
914        "options" => Method::OPTIONS,
915        "head" => Method::HEAD,
916        "patch" => Method::PATCH,
917        "trace" => Method::TRACE,
918        _ => Method::GET,
919    }
920}
921
922/// A GET the document marks as writing is a write; so is anything but a safe
923/// method. Default-closed: the marker can only add writes, never remove them.
924#[cfg(feature = "document")]
925fn effect_of(method: &Method, op: &openapiv3::Operation) -> Effect {
926    if op.extensions.get(WRITES) == Some(&serde_json::Value::Bool(true)) {
927        return Effect::Write;
928    }
929    match *method {
930        Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE => Effect::Read,
931        _ => Effect::Write,
932    }
933}
934
935impl Param {
936    #[cfg(feature = "document")]
937    fn build(
938        op: &str,
939        param: &ReferenceOr<Parameter>,
940        components: &Components,
941        flags: &mut Namespace,
942    ) -> Result<Self, LoadError> {
943        let param = resolve(param, |key| components.parameters.get(key), "parameters")?;
944        let data = param.parameter_data_ref();
945        let shape = shape_of(op, param, data, components, flags)?;
946        if let Shape::Unreachable(why) = &shape
947            && data.required
948        {
949            return Err(LoadError::Parameter {
950                op: op.to_owned(),
951                name: data.name.clone(),
952                why: why.clone(),
953            });
954        }
955        Ok(Self {
956            name: data.name.clone(),
957            required: data.required,
958            shape,
959            description: data.description.clone(),
960        })
961    }
962
963    /// The wire name, as the document spells it.
964    #[must_use]
965    pub fn name(&self) -> &str {
966        &self.name
967    }
968
969    /// What this parameter is worth on a command line: a flag and everything
970    /// that goes with one, or nothing.
971    #[must_use]
972    pub fn shape(&self) -> &Shape {
973        &self.shape
974    }
975
976    #[must_use]
977    pub fn required(&self) -> bool {
978        self.required
979    }
980
981    #[must_use]
982    pub fn description(&self) -> Option<&str> {
983        self.description.as_deref()
984    }
985}
986
987/// What one parameter is worth on a command line, and the flag it claims when it
988/// is worth one.
989///
990/// Every way of not being worth a flag lands in the same place. The blast radius
991/// of a shape this CLI cannot spell is the operation that declares it, never the
992/// document that holds it.
993#[cfg(feature = "document")]
994fn shape_of(
995    op: &str,
996    param: &Parameter,
997    data: &ParameterData,
998    components: &Components,
999    flags: &mut Namespace,
1000) -> Result<Shape, LoadError> {
1001    let Some((location, style)) = sent_in(param) else {
1002        return Ok(Shape::Unreachable(Unsupported::Cookie));
1003    };
1004    let ParameterSchemaOrContent::Schema(schema) = &data.format else {
1005        return Ok(Shape::Unreachable(Unsupported::Encoded));
1006    };
1007    // One value, or a list of them: the schema is asked first, and an array is
1008    // asked again about its items.
1009    let (scalar, repeats) = if let Some(scalar) = scalar_of(schema, components)? {
1010        (scalar, false)
1011    } else if let Some(scalar) = items_of(schema, components)? {
1012        (scalar, true)
1013    } else {
1014        return Ok(Shape::Unreachable(Unsupported::Structured));
1015    };
1016    runnable(&scalar, op, &data.name)?;
1017    // The style is read whatever the schema is, because it is not only about
1018    // delimiters: `matrix` puts a `;name=` in front of one path value as surely
1019    // as in front of a list. A style written out as `form` instead would be a
1020    // request in a shape the server does not read.
1021    let join = match style {
1022        Ok(join) => join,
1023        Err(style) => return Ok(Shape::Unreachable(Unsupported::Style(style.to_owned()))),
1024    };
1025    Ok(Shape::Flag {
1026        flag: flags.claim(&kebab(&data.name), "param"),
1027        location,
1028        scalar,
1029        join: repeats.then_some(join),
1030    })
1031}
1032
1033/// Where a parameter goes, and how a list of its values would reach it there.
1034///
1035/// One answer, because the second is read off the first: `style` means different
1036/// things in a query and in a path, and `in: cookie` has no answer at all. The
1037/// `Err` carries the document's own spelling of a style this crate does not
1038/// write, so that whatever refuses it can name it — `form` in a query and
1039/// `simple` everywhere else are the two it writes, and they are also the two
1040/// OpenAPI defaults, so a document that says nothing lands on them.
1041#[cfg(feature = "document")]
1042fn sent_in(param: &Parameter) -> Option<(Location, Result<Join, &'static str>)> {
1043    Some(match param {
1044        Parameter::Query {
1045            parameter_data,
1046            style,
1047            ..
1048        } => (Location::Query, query_join(style, parameter_data.explode)),
1049        Parameter::Path { style, .. } => (
1050            Location::Path,
1051            match style {
1052                PathStyle::Simple => Ok(Join::Commas),
1053                PathStyle::Matrix => Err("matrix"),
1054                PathStyle::Label => Err("label"),
1055            },
1056        ),
1057        // `simple` is the only style a header has, and a list under it is
1058        // comma-separated whether or not it explodes.
1059        Parameter::Header { .. } => (Location::Header, Ok(Join::Commas)),
1060        Parameter::Cookie { .. } => return None,
1061    })
1062}
1063
1064/// `form` is what a query parameter defaults to and `explode: true` is what
1065/// `form` defaults to, which is one field per value. The three other styles name
1066/// themselves rather than being written as `form`.
1067#[cfg(feature = "document")]
1068fn query_join(style: &QueryStyle, explode: Option<bool>) -> Result<Join, &'static str> {
1069    match style {
1070        QueryStyle::Form => Ok(match explode {
1071            Some(false) => Join::Commas,
1072            None | Some(true) => Join::Pairs,
1073        }),
1074        QueryStyle::SpaceDelimited => Err("spaceDelimited"),
1075        QueryStyle::PipeDelimited => Err("pipeDelimited"),
1076        QueryStyle::DeepObject => Err("deepObject"),
1077    }
1078}
1079
1080/// The scalar a `type: array` parameter's items are, when its items are one.
1081#[cfg(feature = "document")]
1082fn items_of(
1083    schema: &ReferenceOr<Schema>,
1084    components: &Components,
1085) -> Result<Option<Scalar>, RefError> {
1086    let schema = resolve_schema(schema, components)?;
1087    let SchemaKind::Type(openapiv3::Type::Array(array)) = &schema.schema_kind else {
1088        return Ok(None);
1089    };
1090    let Some(items) = &array.items else {
1091        return Ok(None);
1092    };
1093    scalar_of(&items.clone().unbox(), components)
1094}
1095
1096impl Field {
1097    #[must_use]
1098    pub fn name(&self) -> &str {
1099        &self.name
1100    }
1101
1102    #[must_use]
1103    pub fn flag(&self) -> &str {
1104        &self.flag
1105    }
1106
1107    /// The flag is not the plain kebab-case of the wire name, because that name
1108    /// was already taken in this subcommand.
1109    #[must_use]
1110    pub fn renamed(&self) -> bool {
1111        renamed(&self.flag, &self.name)
1112    }
1113
1114    #[must_use]
1115    pub fn required(&self) -> bool {
1116        self.required
1117    }
1118
1119    #[must_use]
1120    pub fn scalar(&self) -> &Scalar {
1121        &self.scalar
1122    }
1123
1124    #[must_use]
1125    pub fn description(&self) -> Option<&str> {
1126        self.description.as_deref()
1127    }
1128}
1129
1130impl Body {
1131    #[cfg(feature = "document")]
1132    fn build(
1133        id: &str,
1134        op: &openapiv3::Operation,
1135        components: &Components,
1136        flags: &mut Namespace,
1137    ) -> Result<Self, LoadError> {
1138        let Some(body) = &op.request_body else {
1139            return Ok(Self::None);
1140        };
1141        let body = resolve(
1142            body,
1143            |key| components.request_bodies.get(key),
1144            "requestBodies",
1145        )?;
1146        let required = body.required;
1147        // The JSON entry if the document offers one, else whatever it offers
1148        // first.
1149        let entry = body
1150            .content
1151            .iter()
1152            .find(|(name, _)| is_json(name))
1153            .or_else(|| body.content.iter().next());
1154        let Some((media_type, media)) = entry else {
1155            return Ok(Self::None);
1156        };
1157        // The key this operation would be sent under, and only that one: a key
1158        // beside it that nothing here ever reads is the vendor's business, and
1159        // refusing over it would refuse documents this crate serves correctly.
1160        if !is_media_type(media_type) {
1161            return Err(LoadError::MediaType {
1162                op: id.to_owned(),
1163                media_type: media_type.clone(),
1164            });
1165        }
1166        if is_multipart(media_type) {
1167            return Ok(Self::Multipart {
1168                names: part_names(media, components),
1169                required,
1170            });
1171        }
1172        if !is_json(media_type) {
1173            return Ok(Self::Opaque {
1174                media_type: media_type.clone(),
1175                required,
1176            });
1177        }
1178        let Some(schema) = &media.schema else {
1179            return Ok(Self::JsonWhole { required });
1180        };
1181        let schema = resolve_schema(schema, components)?;
1182        let SchemaKind::Type(openapiv3::Type::Object(object)) = &schema.schema_kind else {
1183            return Ok(Self::JsonWhole { required });
1184        };
1185
1186        let mut fields = Vec::with_capacity(object.properties.len());
1187        for (name, property) in &object.properties {
1188            let property = property.clone().unbox();
1189            let Some(scalar) = scalar_of(&property, components)? else {
1190                // One nested property is enough: the whole body goes through
1191                // `--json-body`, and no sibling gets a flag the request builder
1192                // would then throw away.
1193                return Ok(Self::JsonWhole { required });
1194            };
1195            runnable(&scalar, id, name)?;
1196            fields.push(Field {
1197                flag: flags.claim(&kebab(name), "body"),
1198                name: name.clone(),
1199                required: required && object.required.iter().any(|r| r == name),
1200                scalar,
1201                description: description_of(&property, components)?,
1202            });
1203        }
1204        Ok(Self::JsonFields(fields))
1205    }
1206}
1207
1208/// Refuse a rule that cannot be run, naming the operation and the value it was
1209/// stated about.
1210///
1211/// A `pattern` the engine cannot read refuses every value, so a document that
1212/// states one describes a flag nothing can satisfy. Saying so while the
1213/// document is reduced is what keeps that a bless-time failure rather than a
1214/// user's.
1215#[cfg(feature = "document")]
1216fn runnable(scalar: &Scalar, op: &str, name: &str) -> Result<(), LoadError> {
1217    scalar.runnable().map_err(|source| LoadError::Unrunnable {
1218        op: op.to_owned(),
1219        name: name.to_owned(),
1220        source,
1221    })
1222}
1223
1224/// The part names a multipart schema declares, in document order.
1225#[cfg(feature = "document")]
1226fn part_names(media: &openapiv3::MediaType, components: &Components) -> Vec<String> {
1227    let Some(schema) = &media.schema else {
1228        return Vec::new();
1229    };
1230    let Ok(schema) = resolve_schema(schema, components) else {
1231        return Vec::new();
1232    };
1233    let SchemaKind::Type(openapiv3::Type::Object(object)) = &schema.schema_kind else {
1234        return Vec::new();
1235    };
1236    object.properties.keys().cloned().collect()
1237}
1238
1239/// `http::Uri` is not a `serde` type; the blob carries the string it prints as.
1240mod uri_string {
1241    use http::Uri;
1242    use serde::{Deserialize, Deserializer, Serializer};
1243
1244    pub(super) fn serialize<S: Serializer>(uri: &Uri, out: S) -> Result<S::Ok, S::Error> {
1245        out.collect_str(uri)
1246    }
1247
1248    pub(super) fn deserialize<'de, D: Deserializer<'de>>(input: D) -> Result<Uri, D::Error> {
1249        let raw = String::deserialize(input)?;
1250        raw.parse().map_err(serde::de::Error::custom)
1251    }
1252}
1253
1254/// `http::Method` is not a `serde` type either, and the eight OpenAPI methods
1255/// are exactly the ones it spells as constants.
1256mod method_string {
1257    use http::Method;
1258    use serde::{Deserialize, Deserializer, Serializer};
1259
1260    pub(super) fn serialize<S: Serializer>(method: &Method, out: S) -> Result<S::Ok, S::Error> {
1261        out.serialize_str(method.as_str())
1262    }
1263
1264    pub(super) fn deserialize<'de, D: Deserializer<'de>>(input: D) -> Result<Method, D::Error> {
1265        let raw = String::deserialize(input)?;
1266        raw.parse().map_err(serde::de::Error::custom)
1267    }
1268}