use http::{Method, Uri};
#[cfg(feature = "document")]
use openapiv3::{
Components, OpenAPI, Parameter, ParameterSchemaOrContent, ReferenceOr, SchemaKind,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::names::{CommandName, renamed};
#[cfg(feature = "document")]
use crate::names::{Grouping, NameError, Namespace, kebab};
use crate::scalar::Scalar;
#[cfg(feature = "document")]
use crate::schema::{RefError, is_json, is_multipart, resolve, resolve_schema, scalar_of};
#[cfg(feature = "document")]
const WRITES: &str = "x-cli-writes";
#[cfg(feature = "document")]
const COMMAND: &str = "x-cli-command";
#[cfg(feature = "document")]
const GROUP: &str = "x-cli-group";
pub const JSON_BODY: &str = "json-body";
pub const RAW_BODY: &str = "raw-body";
pub const FILE_PART: &str = "file";
pub const FIELD_PART: &str = "field";
pub const COMMIT: &str = "commit";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Document {
#[serde(with = "uri_string")]
base: Uri,
ops: Vec<Operation>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Operation {
id: String,
group: CommandName,
command: CommandName,
#[serde(with = "method_string")]
method: Method,
path: String,
summary: Option<String>,
description: Option<String>,
params: Vec<Param>,
body: Body,
effect: Effect,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Effect {
Read,
Write,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Location {
Path,
Query,
Header,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Param {
name: String,
flag: String,
location: Location,
required: bool,
scalar: Scalar,
description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Field {
name: String,
flag: String,
required: bool,
scalar: Scalar,
description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Body {
None,
JsonFields(Vec<Field>),
JsonWhole { required: bool },
Multipart { names: Vec<String>, required: bool },
Opaque { media_type: String, required: bool },
}
#[derive(Debug, Error)]
pub enum DocumentError {
#[error("the reduced model the bless step writes is not valid: {0}")]
Blob(#[from] postcard::Error),
}
#[cfg(feature = "document")]
#[derive(Debug, Error)]
pub enum LoadError {
#[error(transparent)]
Overlay(#[from] crate::overlay::OverlayError),
#[error("the overlaid document is not an OpenAPI 3 document: {0}")]
Shape(#[source] serde_json::Error),
#[error("no `servers` entry to send requests to")]
NoServer,
#[error("`servers[0].url` ({url}) is not a URL: {source}")]
ServerUrl {
url: String,
#[source]
source: http::uri::InvalidUri,
},
#[error("{method} {path} has no operationId")]
NoOperationId { method: String, path: String },
#[error(transparent)]
Name(#[from] NameError),
#[error("{op}: `{key}` is not a string")]
Override { op: String, key: &'static str },
#[error(
"`{first}` and `{second}` are both `{group} {command}` on the command line; \
give one of them an `x-cli-command`"
)]
DuplicateCommand {
group: CommandName,
command: CommandName,
first: String,
second: String,
},
#[error(transparent)]
Reference(#[from] RefError),
#[error("{op}: parameter `{name}` is {reason}")]
Parameter {
op: String,
name: String,
reason: &'static str,
},
#[error("{op}: `{name}`: {source}")]
Unrunnable {
op: String,
name: String,
#[source]
source: crate::scalar::ScalarError,
},
}
#[derive(Debug, Clone, Error, PartialEq, Eq)]
#[error(
"the document and the generated inventory disagree at operation {position}: \
the inventory says `{expected}` and the document says {}",
found.as_deref().map_or_else(|| "there is no such operation".to_owned(), |id| format!("`{id}`"))
)]
pub struct DriftError {
pub position: usize,
pub expected: String,
pub found: Option<String>,
}
impl Document {
#[cfg(feature = "document")]
pub fn load(document: &str, overlays: &[&str]) -> Result<Self, LoadError> {
let mut doc = crate::overlay::parse(document)?;
for overlay in overlays {
doc = crate::overlay::apply(doc, overlay)?;
}
let doc: OpenAPI = serde_json::from_value(doc).map_err(LoadError::Shape)?;
Self::from_openapi(&doc)
}
pub fn from_blob(blob: &[u8]) -> Result<Self, DocumentError> {
Ok(postcard::from_bytes(blob)?)
}
pub fn to_blob(&self) -> Result<Vec<u8>, DocumentError> {
Ok(postcard::to_allocvec(self)?)
}
#[cfg(feature = "document")]
fn from_openapi(doc: &OpenAPI) -> Result<Self, LoadError> {
let server = doc.servers.first().ok_or(LoadError::NoServer)?;
let base = Uri::try_from(&server.url).map_err(|source| LoadError::ServerUrl {
url: server.url.clone(),
source,
})?;
let empty = Components::default();
let paths: Vec<&str> = doc.paths.paths.keys().map(String::as_str).collect();
let whole = Reading {
components: doc.components.as_ref().unwrap_or(&empty),
grouping: Grouping::of(&paths),
};
let mut ops: Vec<Operation> = Vec::new();
for (path, item) in &doc.paths.paths {
let item = item.as_item().ok_or_else(|| RefError {
reference: format!("paths[{path}]"),
})?;
for (method, op) in item.iter() {
let params = item.parameters.iter().chain(op.parameters.iter());
ops.push(Operation::build(path, method, op, params, whole)?);
}
}
if let Some(collision) = first_collision(&ops) {
return Err(collision);
}
Ok(Self { base, ops })
}
#[must_use]
pub fn base(&self) -> &Uri {
&self.base
}
pub fn iter(&self) -> std::slice::Iter<'_, Operation> {
self.ops.iter()
}
#[must_use]
pub fn get(&self, operation_id: &str) -> Option<&Operation> {
self.ops.iter().find(|op| op.id == operation_id)
}
#[must_use]
pub fn by_command(&self, group: &str, command: &str) -> Option<&Operation> {
self.ops
.iter()
.find(|op| op.group.as_str() == group && op.command.as_str() == command)
}
#[must_use]
pub fn operations(&self) -> &[Operation] {
&self.ops
}
pub fn matches(&self, inventory: &[(&str, &str, &str)]) -> Result<(), DriftError> {
let drift = |position: usize, expected: &str| DriftError {
position,
expected: expected.to_owned(),
found: self.ops.get(position).map(|op| op.id.clone()),
};
for (position, (id, method, path)) in inventory.iter().enumerate() {
match self.ops.get(position) {
Some(op) if op.id == *id && op.method == *method && op.path == *path => {}
_ => return Err(drift(position, id)),
}
}
match self.ops.get(inventory.len()) {
None => Ok(()),
Some(extra) => Err(DriftError {
position: inventory.len(),
expected: "nothing after it".to_owned(),
found: Some(extra.id.clone()),
}),
}
}
}
impl<'a> IntoIterator for &'a Document {
type Item = &'a Operation;
type IntoIter = std::slice::Iter<'a, Operation>;
fn into_iter(self) -> Self::IntoIter {
self.ops.iter()
}
}
impl Operation {
#[cfg(feature = "document")]
fn build<'d>(
path: &str,
method: &str,
op: &openapiv3::Operation,
params: impl Iterator<Item = &'d ReferenceOr<Parameter>>,
whole: Reading<'_>,
) -> Result<Self, LoadError> {
let id = op
.operation_id
.as_deref()
.ok_or_else(|| LoadError::NoOperationId {
method: method.to_owned(),
path: path.to_owned(),
})?;
let method = method_of(method);
let (group, command) = placement(op, id, path, &method, whole.grouping)?;
let mut flags =
Namespace::with_reserved([COMMIT, JSON_BODY, RAW_BODY, FILE_PART, FIELD_PART]);
let params = params
.map(|p| Param::build(id, p, whole.components, &mut flags))
.collect::<Result<Vec<_>, _>>()?;
let body = Body::build(id, op, whole.components, &mut flags)?;
let effect = effect_of(&method, op);
Ok(Self {
id: id.to_owned(),
group,
command,
method,
path: path.to_owned(),
summary: op.summary.clone(),
description: op.description.clone(),
params,
body,
effect,
})
}
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub fn group(&self) -> &CommandName {
&self.group
}
#[must_use]
pub fn command(&self) -> &CommandName {
&self.command
}
#[must_use]
pub fn method(&self) -> &Method {
&self.method
}
#[must_use]
pub fn path(&self) -> &str {
&self.path
}
#[must_use]
pub fn summary(&self) -> Option<&str> {
self.summary.as_deref()
}
#[must_use]
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
#[must_use]
pub fn params(&self) -> &[Param] {
&self.params
}
#[must_use]
pub fn body(&self) -> &Body {
&self.body
}
#[must_use]
pub fn effect(&self) -> Effect {
self.effect
}
#[must_use]
pub fn param(&self, name: &str) -> Option<&Param> {
self.params.iter().find(|p| p.name == name)
}
}
#[cfg(feature = "document")]
#[derive(Debug, Clone, Copy)]
struct Reading<'d> {
components: &'d Components,
grouping: Grouping,
}
#[cfg(feature = "document")]
fn placement(
op: &openapiv3::Operation,
id: &str,
path: &str,
method: &Method,
grouping: Grouping,
) -> Result<(CommandName, CommandName), LoadError> {
let group = match named(op, id, GROUP)? {
Some(raw) => CommandName::new(GROUP, raw)?,
None => grouping.group(path)?,
};
let command = match named(op, id, COMMAND)? {
Some(raw) => CommandName::new(COMMAND, raw)?,
None => grouping.leaf(path, method)?,
};
Ok((group, command))
}
#[cfg(feature = "document")]
fn named<'o>(
op: &'o openapiv3::Operation,
id: &str,
key: &'static str,
) -> Result<Option<&'o str>, LoadError> {
match op.extensions.get(key) {
None => Ok(None),
Some(serde_json::Value::String(raw)) => Ok(Some(raw)),
Some(_) => Err(LoadError::Override {
op: id.to_owned(),
key,
}),
}
}
#[cfg(feature = "document")]
fn first_collision(ops: &[Operation]) -> Option<LoadError> {
ops.iter().enumerate().find_map(|(index, op)| {
let later = ops
.get(index + 1..)?
.iter()
.find(|later| later.group == op.group && later.command == op.command)?;
Some(LoadError::DuplicateCommand {
group: op.group.clone(),
command: op.command.clone(),
first: op.id.clone(),
second: later.id.clone(),
})
})
}
#[cfg(feature = "document")]
fn method_of(name: &str) -> Method {
match name {
"put" => Method::PUT,
"post" => Method::POST,
"delete" => Method::DELETE,
"options" => Method::OPTIONS,
"head" => Method::HEAD,
"patch" => Method::PATCH,
"trace" => Method::TRACE,
_ => Method::GET,
}
}
#[cfg(feature = "document")]
fn effect_of(method: &Method, op: &openapiv3::Operation) -> Effect {
if op.extensions.get(WRITES) == Some(&serde_json::Value::Bool(true)) {
return Effect::Write;
}
match *method {
Method::GET | Method::HEAD | Method::OPTIONS | Method::TRACE => Effect::Read,
_ => Effect::Write,
}
}
impl Param {
#[cfg(feature = "document")]
fn build(
op: &str,
param: &ReferenceOr<Parameter>,
components: &Components,
flags: &mut Namespace,
) -> Result<Self, LoadError> {
let param = resolve(param, |key| components.parameters.get(key), "parameters")?;
let reject = |name: &str, reason: &'static str| LoadError::Parameter {
op: op.to_owned(),
name: name.to_owned(),
reason,
};
let (location, data) = match param {
Parameter::Path { parameter_data, .. } => (Location::Path, parameter_data),
Parameter::Query { parameter_data, .. } => (Location::Query, parameter_data),
Parameter::Header { parameter_data, .. } => (Location::Header, parameter_data),
Parameter::Cookie { parameter_data, .. } => {
return Err(reject(
¶meter_data.name,
"in: cookie, which this CLI does not send",
));
}
};
let ParameterSchemaOrContent::Schema(schema) = &data.format else {
return Err(reject(
&data.name,
"described by `content`, which this CLI does not encode",
));
};
let scalar = scalar_of(schema, components)?
.ok_or_else(|| reject(&data.name, "not a scalar, so it cannot be one flag"))?;
runnable(&scalar, op, &data.name)?;
Ok(Self {
flag: flags.claim(&kebab(&data.name), "param"),
name: data.name.clone(),
location,
required: data.required,
scalar,
description: data.description.clone(),
})
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn flag(&self) -> &str {
&self.flag
}
#[must_use]
pub fn renamed(&self) -> bool {
renamed(&self.flag, &self.name)
}
#[must_use]
pub fn location(&self) -> Location {
self.location
}
#[must_use]
pub fn required(&self) -> bool {
self.required
}
#[must_use]
pub fn scalar(&self) -> &Scalar {
&self.scalar
}
#[must_use]
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
}
impl Field {
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn flag(&self) -> &str {
&self.flag
}
#[must_use]
pub fn renamed(&self) -> bool {
renamed(&self.flag, &self.name)
}
#[must_use]
pub fn required(&self) -> bool {
self.required
}
#[must_use]
pub fn scalar(&self) -> &Scalar {
&self.scalar
}
#[must_use]
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
}
impl Body {
#[cfg(feature = "document")]
fn build(
id: &str,
op: &openapiv3::Operation,
components: &Components,
flags: &mut Namespace,
) -> Result<Self, LoadError> {
let Some(body) = &op.request_body else {
return Ok(Self::None);
};
let body = resolve(
body,
|key| components.request_bodies.get(key),
"requestBodies",
)?;
let required = body.required;
let entry = body
.content
.iter()
.find(|(name, _)| is_json(name))
.or_else(|| body.content.iter().next());
let Some((media_type, media)) = entry else {
return Ok(Self::None);
};
if is_multipart(media_type) {
return Ok(Self::Multipart {
names: part_names(media, components),
required,
});
}
if !is_json(media_type) {
return Ok(Self::Opaque {
media_type: media_type.clone(),
required,
});
}
let Some(schema) = &media.schema else {
return Ok(Self::JsonWhole { required });
};
let schema = resolve_schema(schema, components)?;
let SchemaKind::Type(openapiv3::Type::Object(object)) = &schema.schema_kind else {
return Ok(Self::JsonWhole { required });
};
let mut fields = Vec::with_capacity(object.properties.len());
for (name, property) in &object.properties {
let property = property.clone().unbox();
let Some(scalar) = scalar_of(&property, components)? else {
return Ok(Self::JsonWhole { required });
};
runnable(&scalar, id, name)?;
let described = resolve_schema(&property, components)?;
fields.push(Field {
flag: flags.claim(&kebab(name), "body"),
name: name.clone(),
required: required && object.required.iter().any(|r| r == name),
scalar,
description: described.schema_data.description.clone(),
});
}
Ok(Self::JsonFields(fields))
}
}
#[cfg(feature = "document")]
fn runnable(scalar: &Scalar, op: &str, name: &str) -> Result<(), LoadError> {
scalar.runnable().map_err(|source| LoadError::Unrunnable {
op: op.to_owned(),
name: name.to_owned(),
source,
})
}
#[cfg(feature = "document")]
fn part_names(media: &openapiv3::MediaType, components: &Components) -> Vec<String> {
let Some(schema) = &media.schema else {
return Vec::new();
};
let Ok(schema) = resolve_schema(schema, components) else {
return Vec::new();
};
let SchemaKind::Type(openapiv3::Type::Object(object)) = &schema.schema_kind else {
return Vec::new();
};
object.properties.keys().cloned().collect()
}
mod uri_string {
use http::Uri;
use serde::{Deserialize, Deserializer, Serializer};
pub(super) fn serialize<S: Serializer>(uri: &Uri, out: S) -> Result<S::Ok, S::Error> {
out.collect_str(uri)
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(input: D) -> Result<Uri, D::Error> {
let raw = String::deserialize(input)?;
raw.parse().map_err(serde::de::Error::custom)
}
}
mod method_string {
use http::Method;
use serde::{Deserialize, Deserializer, Serializer};
pub(super) fn serialize<S: Serializer>(method: &Method, out: S) -> Result<S::Ok, S::Error> {
out.serialize_str(method.as_str())
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(input: D) -> Result<Method, D::Error> {
let raw = String::deserialize(input)?;
raw.parse().map_err(serde::de::Error::custom)
}
}