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}