1use http::{Method, Uri};
17#[cfg(feature = "document")]
18use openapiv3::{
19 Components, OpenAPI, Parameter, ParameterSchemaOrContent, ReferenceOr, SchemaKind,
20};
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23
24use crate::names::{CommandName, renamed};
25#[cfg(feature = "document")]
26use crate::names::{Grouping, NameError, Namespace, kebab};
27use crate::scalar::Scalar;
28#[cfg(feature = "document")]
29use crate::schema::{RefError, is_json, is_multipart, resolve, resolve_schema, scalar_of};
30
31#[cfg(feature = "document")]
37const WRITES: &str = "x-cli-writes";
38#[cfg(feature = "document")]
41const COMMAND: &str = "x-cli-command";
42#[cfg(feature = "document")]
45const GROUP: &str = "x-cli-group";
46
47pub const JSON_BODY: &str = "json-body";
49pub const RAW_BODY: &str = "raw-body";
51pub const FILE_PART: &str = "file";
53pub const FIELD_PART: &str = "field";
55pub const COMMIT: &str = "commit";
57
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct Document {
62 #[serde(with = "uri_string")]
63 base: Uri,
64 ops: Vec<Operation>,
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
69pub struct Operation {
70 id: String,
71 group: CommandName,
72 command: CommandName,
73 #[serde(with = "method_string")]
74 method: Method,
75 path: String,
76 summary: Option<String>,
77 description: Option<String>,
78 params: Vec<Param>,
79 body: Body,
80 effect: Effect,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85pub enum Effect {
86 Read,
88 Write,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94pub enum Location {
95 Path,
96 Query,
97 Header,
98}
99
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
102pub struct Param {
103 name: String,
104 flag: String,
105 location: Location,
106 required: bool,
107 scalar: Scalar,
108 description: Option<String>,
109}
110
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
113pub struct Field {
114 name: String,
115 flag: String,
116 required: bool,
117 scalar: Scalar,
118 description: Option<String>,
119}
120
121#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
125pub enum Body {
126 None,
128 JsonFields(Vec<Field>),
131 JsonWhole { required: bool },
135 Multipart { names: Vec<String>, required: bool },
141 Opaque { media_type: String, required: bool },
144}
145
146#[derive(Debug, Error)]
157pub enum DocumentError {
158 #[error("the reduced model the bless step writes is not valid: {0}")]
159 Blob(#[from] postcard::Error),
160}
161
162#[cfg(feature = "document")]
169#[derive(Debug, Error)]
170pub enum LoadError {
171 #[error(transparent)]
172 Overlay(#[from] crate::overlay::OverlayError),
173 #[error("the overlaid document is not an OpenAPI 3 document: {0}")]
174 Shape(#[source] serde_json::Error),
175 #[error("no `servers` entry to send requests to")]
176 NoServer,
177 #[error("`servers[0].url` ({url}) is not a URL: {source}")]
178 ServerUrl {
179 url: String,
180 #[source]
181 source: http::uri::InvalidUri,
182 },
183 #[error("{method} {path} has no operationId")]
184 NoOperationId { method: String, path: String },
185 #[error(transparent)]
186 Name(#[from] NameError),
187 #[error("{op}: `{key}` is not a string")]
188 Override { op: String, key: &'static str },
189 #[error(
190 "`{first}` and `{second}` are both `{group} {command}` on the command line; \
191 give one of them an `x-cli-command`"
192 )]
193 DuplicateCommand {
194 group: CommandName,
195 command: CommandName,
196 first: String,
197 second: String,
198 },
199 #[error(transparent)]
200 Reference(#[from] RefError),
201 #[error("{op}: parameter `{name}` is {reason}")]
202 Parameter {
203 op: String,
204 name: String,
205 reason: &'static str,
206 },
207 #[error("{op}: `{name}`: {source}")]
211 Unrunnable {
212 op: String,
213 name: String,
214 #[source]
215 source: crate::scalar::ScalarError,
216 },
217}
218
219#[derive(Debug, Clone, Error, PartialEq, Eq)]
225#[error(
226 "the document and the generated inventory disagree at operation {position}: \
227 the inventory says `{expected}` and the document says {}",
228 found.as_deref().map_or_else(|| "there is no such operation".to_owned(), |id| format!("`{id}`"))
229)]
230pub struct DriftError {
231 pub position: usize,
232 pub expected: String,
233 pub found: Option<String>,
234}
235
236impl Document {
237 #[cfg(feature = "document")]
268 pub fn load(document: &str, overlays: &[&str]) -> Result<Self, LoadError> {
269 let mut doc = crate::overlay::parse(document)?;
270 for overlay in overlays {
271 doc = crate::overlay::apply(doc, overlay)?;
272 }
273 let doc: OpenAPI = serde_json::from_value(doc).map_err(LoadError::Shape)?;
274 Self::from_openapi(&doc)
275 }
276
277 pub fn from_blob(blob: &[u8]) -> Result<Self, DocumentError> {
285 Ok(postcard::from_bytes(blob)?)
286 }
287
288 pub fn to_blob(&self) -> Result<Vec<u8>, DocumentError> {
295 Ok(postcard::to_allocvec(self)?)
296 }
297
298 #[cfg(feature = "document")]
299 fn from_openapi(doc: &OpenAPI) -> Result<Self, LoadError> {
300 let server = doc.servers.first().ok_or(LoadError::NoServer)?;
301 let base = Uri::try_from(&server.url).map_err(|source| LoadError::ServerUrl {
302 url: server.url.clone(),
303 source,
304 })?;
305 let empty = Components::default();
306 let paths: Vec<&str> = doc.paths.paths.keys().map(String::as_str).collect();
309 let whole = Reading {
310 components: doc.components.as_ref().unwrap_or(&empty),
311 grouping: Grouping::of(&paths),
312 };
313
314 let mut ops: Vec<Operation> = Vec::new();
315 for (path, item) in &doc.paths.paths {
316 let item = item.as_item().ok_or_else(|| RefError {
317 reference: format!("paths[{path}]"),
318 })?;
319 for (method, op) in item.iter() {
320 let params = item.parameters.iter().chain(op.parameters.iter());
321 ops.push(Operation::build(path, method, op, params, whole)?);
322 }
323 }
324 if let Some(collision) = first_collision(&ops) {
325 return Err(collision);
326 }
327 Ok(Self { base, ops })
328 }
329
330 #[must_use]
332 pub fn base(&self) -> &Uri {
333 &self.base
334 }
335
336 pub fn iter(&self) -> std::slice::Iter<'_, Operation> {
337 self.ops.iter()
338 }
339
340 #[must_use]
343 pub fn get(&self, operation_id: &str) -> Option<&Operation> {
344 self.ops.iter().find(|op| op.id == operation_id)
345 }
346
347 #[must_use]
349 pub fn by_command(&self, group: &str, command: &str) -> Option<&Operation> {
350 self.ops
351 .iter()
352 .find(|op| op.group.as_str() == group && op.command.as_str() == command)
353 }
354
355 #[must_use]
358 pub fn operations(&self) -> &[Operation] {
359 &self.ops
360 }
361
362 pub fn matches(&self, inventory: &[(&str, &str, &str)]) -> Result<(), DriftError> {
369 let drift = |position: usize, expected: &str| DriftError {
370 position,
371 expected: expected.to_owned(),
372 found: self.ops.get(position).map(|op| op.id.clone()),
373 };
374 for (position, (id, method, path)) in inventory.iter().enumerate() {
375 match self.ops.get(position) {
376 Some(op) if op.id == *id && op.method == *method && op.path == *path => {}
377 _ => return Err(drift(position, id)),
378 }
379 }
380 match self.ops.get(inventory.len()) {
381 None => Ok(()),
382 Some(extra) => Err(DriftError {
383 position: inventory.len(),
384 expected: "nothing after it".to_owned(),
385 found: Some(extra.id.clone()),
386 }),
387 }
388 }
389}
390
391impl<'a> IntoIterator for &'a Document {
392 type Item = &'a Operation;
393 type IntoIter = std::slice::Iter<'a, Operation>;
394
395 fn into_iter(self) -> Self::IntoIter {
396 self.ops.iter()
397 }
398}
399
400impl Operation {
401 #[cfg(feature = "document")]
402 fn build<'d>(
403 path: &str,
404 method: &str,
405 op: &openapiv3::Operation,
406 params: impl Iterator<Item = &'d ReferenceOr<Parameter>>,
407 whole: Reading<'_>,
408 ) -> Result<Self, LoadError> {
409 let id = op
410 .operation_id
411 .as_deref()
412 .ok_or_else(|| LoadError::NoOperationId {
413 method: method.to_owned(),
414 path: path.to_owned(),
415 })?;
416 let method = method_of(method);
417 let (group, command) = placement(op, id, path, &method, whole.grouping)?;
418
419 let mut flags =
421 Namespace::with_reserved([COMMIT, JSON_BODY, RAW_BODY, FILE_PART, FIELD_PART]);
422 let params = params
423 .map(|p| Param::build(id, p, whole.components, &mut flags))
424 .collect::<Result<Vec<_>, _>>()?;
425 let body = Body::build(id, op, whole.components, &mut flags)?;
426 let effect = effect_of(&method, op);
427
428 Ok(Self {
429 id: id.to_owned(),
430 group,
431 command,
432 method,
433 path: path.to_owned(),
434 summary: op.summary.clone(),
435 description: op.description.clone(),
436 params,
437 body,
438 effect,
439 })
440 }
441
442 #[must_use]
444 pub fn id(&self) -> &str {
445 &self.id
446 }
447
448 #[must_use]
450 pub fn group(&self) -> &CommandName {
451 &self.group
452 }
453
454 #[must_use]
456 pub fn command(&self) -> &CommandName {
457 &self.command
458 }
459
460 #[must_use]
461 pub fn method(&self) -> &Method {
462 &self.method
463 }
464
465 #[must_use]
467 pub fn path(&self) -> &str {
468 &self.path
469 }
470
471 #[must_use]
472 pub fn summary(&self) -> Option<&str> {
473 self.summary.as_deref()
474 }
475
476 #[must_use]
477 pub fn description(&self) -> Option<&str> {
478 self.description.as_deref()
479 }
480
481 #[must_use]
482 pub fn params(&self) -> &[Param] {
483 &self.params
484 }
485
486 #[must_use]
487 pub fn body(&self) -> &Body {
488 &self.body
489 }
490
491 #[must_use]
492 pub fn effect(&self) -> Effect {
493 self.effect
494 }
495
496 #[must_use]
498 pub fn param(&self, name: &str) -> Option<&Param> {
499 self.params.iter().find(|p| p.name == name)
500 }
501}
502
503#[cfg(feature = "document")]
508#[derive(Debug, Clone, Copy)]
509struct Reading<'d> {
510 components: &'d Components,
511 grouping: Grouping,
512}
513
514#[cfg(feature = "document")]
521fn placement(
522 op: &openapiv3::Operation,
523 id: &str,
524 path: &str,
525 method: &Method,
526 grouping: Grouping,
527) -> Result<(CommandName, CommandName), LoadError> {
528 let group = match named(op, id, GROUP)? {
529 Some(raw) => CommandName::new(GROUP, raw)?,
530 None => grouping.group(path)?,
531 };
532 let command = match named(op, id, COMMAND)? {
533 Some(raw) => CommandName::new(COMMAND, raw)?,
534 None => grouping.leaf(path, method)?,
535 };
536 Ok((group, command))
537}
538
539#[cfg(feature = "document")]
546fn named<'o>(
547 op: &'o openapiv3::Operation,
548 id: &str,
549 key: &'static str,
550) -> Result<Option<&'o str>, LoadError> {
551 match op.extensions.get(key) {
552 None => Ok(None),
553 Some(serde_json::Value::String(raw)) => Ok(Some(raw)),
554 Some(_) => Err(LoadError::Override {
555 op: id.to_owned(),
556 key,
557 }),
558 }
559}
560
561#[cfg(feature = "document")]
565fn first_collision(ops: &[Operation]) -> Option<LoadError> {
566 ops.iter().enumerate().find_map(|(index, op)| {
567 let later = ops
568 .get(index + 1..)?
569 .iter()
570 .find(|later| later.group == op.group && later.command == op.command)?;
571 Some(LoadError::DuplicateCommand {
572 group: op.group.clone(),
573 command: op.command.clone(),
574 first: op.id.clone(),
575 second: later.id.clone(),
576 })
577 })
578}
579
580#[cfg(feature = "document")]
582fn method_of(name: &str) -> Method {
583 match name {
584 "put" => Method::PUT,
585 "post" => Method::POST,
586 "delete" => Method::DELETE,
587 "options" => Method::OPTIONS,
588 "head" => Method::HEAD,
589 "patch" => Method::PATCH,
590 "trace" => Method::TRACE,
591 _ => Method::GET,
592 }
593}
594
595#[cfg(feature = "document")]
598fn effect_of(method: &Method, op: &openapiv3::Operation) -> Effect {
599 if op.extensions.get(WRITES) == Some(&serde_json::Value::Bool(true)) {
600 return Effect::Write;
601 }
602 match *method {
603 Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE => Effect::Read,
604 _ => Effect::Write,
605 }
606}
607
608impl Param {
609 #[cfg(feature = "document")]
610 fn build(
611 op: &str,
612 param: &ReferenceOr<Parameter>,
613 components: &Components,
614 flags: &mut Namespace,
615 ) -> Result<Self, LoadError> {
616 let param = resolve(param, |key| components.parameters.get(key), "parameters")?;
617 let reject = |name: &str, reason: &'static str| LoadError::Parameter {
618 op: op.to_owned(),
619 name: name.to_owned(),
620 reason,
621 };
622 let (location, data) = match param {
623 Parameter::Path { parameter_data, .. } => (Location::Path, parameter_data),
624 Parameter::Query { parameter_data, .. } => (Location::Query, parameter_data),
625 Parameter::Header { parameter_data, .. } => (Location::Header, parameter_data),
626 Parameter::Cookie { parameter_data, .. } => {
627 return Err(reject(
628 ¶meter_data.name,
629 "in: cookie, which this CLI does not send",
630 ));
631 }
632 };
633 let ParameterSchemaOrContent::Schema(schema) = &data.format else {
634 return Err(reject(
635 &data.name,
636 "described by `content`, which this CLI does not encode",
637 ));
638 };
639 let scalar = scalar_of(schema, components)?
640 .ok_or_else(|| reject(&data.name, "not a scalar, so it cannot be one flag"))?;
641 runnable(&scalar, op, &data.name)?;
642 Ok(Self {
643 flag: flags.claim(&kebab(&data.name), "param"),
644 name: data.name.clone(),
645 location,
646 required: data.required,
647 scalar,
648 description: data.description.clone(),
649 })
650 }
651
652 #[must_use]
654 pub fn name(&self) -> &str {
655 &self.name
656 }
657
658 #[must_use]
660 pub fn flag(&self) -> &str {
661 &self.flag
662 }
663
664 #[must_use]
667 pub fn renamed(&self) -> bool {
668 renamed(&self.flag, &self.name)
669 }
670
671 #[must_use]
672 pub fn location(&self) -> Location {
673 self.location
674 }
675
676 #[must_use]
677 pub fn required(&self) -> bool {
678 self.required
679 }
680
681 #[must_use]
682 pub fn scalar(&self) -> &Scalar {
683 &self.scalar
684 }
685
686 #[must_use]
687 pub fn description(&self) -> Option<&str> {
688 self.description.as_deref()
689 }
690}
691
692impl Field {
693 #[must_use]
694 pub fn name(&self) -> &str {
695 &self.name
696 }
697
698 #[must_use]
699 pub fn flag(&self) -> &str {
700 &self.flag
701 }
702
703 #[must_use]
706 pub fn renamed(&self) -> bool {
707 renamed(&self.flag, &self.name)
708 }
709
710 #[must_use]
711 pub fn required(&self) -> bool {
712 self.required
713 }
714
715 #[must_use]
716 pub fn scalar(&self) -> &Scalar {
717 &self.scalar
718 }
719
720 #[must_use]
721 pub fn description(&self) -> Option<&str> {
722 self.description.as_deref()
723 }
724}
725
726impl Body {
727 #[cfg(feature = "document")]
728 fn build(
729 id: &str,
730 op: &openapiv3::Operation,
731 components: &Components,
732 flags: &mut Namespace,
733 ) -> Result<Self, LoadError> {
734 let Some(body) = &op.request_body else {
735 return Ok(Self::None);
736 };
737 let body = resolve(
738 body,
739 |key| components.request_bodies.get(key),
740 "requestBodies",
741 )?;
742 let required = body.required;
743 let entry = body
746 .content
747 .iter()
748 .find(|(name, _)| is_json(name))
749 .or_else(|| body.content.iter().next());
750 let Some((media_type, media)) = entry else {
751 return Ok(Self::None);
752 };
753 if is_multipart(media_type) {
754 return Ok(Self::Multipart {
755 names: part_names(media, components),
756 required,
757 });
758 }
759 if !is_json(media_type) {
760 return Ok(Self::Opaque {
761 media_type: media_type.clone(),
762 required,
763 });
764 }
765 let Some(schema) = &media.schema else {
766 return Ok(Self::JsonWhole { required });
767 };
768 let schema = resolve_schema(schema, components)?;
769 let SchemaKind::Type(openapiv3::Type::Object(object)) = &schema.schema_kind else {
770 return Ok(Self::JsonWhole { required });
771 };
772
773 let mut fields = Vec::with_capacity(object.properties.len());
774 for (name, property) in &object.properties {
775 let property = property.clone().unbox();
776 let Some(scalar) = scalar_of(&property, components)? else {
777 return Ok(Self::JsonWhole { required });
781 };
782 runnable(&scalar, id, name)?;
783 let described = resolve_schema(&property, components)?;
784 fields.push(Field {
785 flag: flags.claim(&kebab(name), "body"),
786 name: name.clone(),
787 required: required && object.required.iter().any(|r| r == name),
788 scalar,
789 description: described.schema_data.description.clone(),
790 });
791 }
792 Ok(Self::JsonFields(fields))
793 }
794}
795
796#[cfg(feature = "document")]
804fn runnable(scalar: &Scalar, op: &str, name: &str) -> Result<(), LoadError> {
805 scalar.runnable().map_err(|source| LoadError::Unrunnable {
806 op: op.to_owned(),
807 name: name.to_owned(),
808 source,
809 })
810}
811
812#[cfg(feature = "document")]
814fn part_names(media: &openapiv3::MediaType, components: &Components) -> Vec<String> {
815 let Some(schema) = &media.schema else {
816 return Vec::new();
817 };
818 let Ok(schema) = resolve_schema(schema, components) else {
819 return Vec::new();
820 };
821 let SchemaKind::Type(openapiv3::Type::Object(object)) = &schema.schema_kind else {
822 return Vec::new();
823 };
824 object.properties.keys().cloned().collect()
825}
826
827mod uri_string {
829 use http::Uri;
830 use serde::{Deserialize, Deserializer, Serializer};
831
832 pub(super) fn serialize<S: Serializer>(uri: &Uri, out: S) -> Result<S::Ok, S::Error> {
833 out.collect_str(uri)
834 }
835
836 pub(super) fn deserialize<'de, D: Deserializer<'de>>(input: D) -> Result<Uri, D::Error> {
837 let raw = String::deserialize(input)?;
838 raw.parse().map_err(serde::de::Error::custom)
839 }
840}
841
842mod method_string {
845 use http::Method;
846 use serde::{Deserialize, Deserializer, Serializer};
847
848 pub(super) fn serialize<S: Serializer>(method: &Method, out: S) -> Result<S::Ok, S::Error> {
849 out.serialize_str(method.as_str())
850 }
851
852 pub(super) fn deserialize<'de, D: Deserializer<'de>>(input: D) -> Result<Method, D::Error> {
853 let raw = String::deserialize(input)?;
854 raw.parse().map_err(serde::de::Error::custom)
855 }
856}