use http::{Method, Uri};
#[cfg(feature = "document")]
use openapiv3::{
Components, OpenAPI, Parameter, ParameterData, ParameterSchemaOrContent, PathStyle, QueryStyle,
ReferenceOr, Schema, SchemaKind,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::names::{CommandName, NameError, renamed, spelled};
#[cfg(feature = "document")]
use crate::names::{Grouping, Namespace, kebab};
use crate::scalar::Scalar;
#[cfg(feature = "document")]
use crate::schema::{
RefError, description_of, is_json, is_media_type, 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";
#[cfg(feature = "document")]
const GATES: &str = "x-cli-gates";
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";
#[cfg(feature = "document")]
const RESERVED: [&str; 5] = [COMMIT, JSON_BODY, RAW_BODY, FILE_PART, FIELD_PART];
#[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,
gates: Vec<Gate>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Effect {
Read,
Write,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Gate(String);
impl Gate {
pub fn new(origin: &'static str, raw: &str) -> Result<Self, NameError> {
spelled(origin, raw).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for Gate {
type Error = NameError;
fn try_from(raw: String) -> Result<Self, NameError> {
Self::new("reduced model", &raw)
}
}
impl From<Gate> for String {
fn from(gate: Gate) -> Self {
gate.0
}
}
impl std::fmt::Display for Gate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[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,
required: bool,
shape: Shape,
description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Shape {
Flag {
flag: String,
location: Location,
scalar: Scalar,
join: Option<Join>,
},
Unreachable(Unsupported),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Join {
Pairs,
Commas,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Unsupported {
Cookie,
Encoded,
Structured,
Style(String),
}
impl std::fmt::Display for Unsupported {
fn fmt(&self, out: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Cookie => out.write_str("`in: cookie`, which this CLI does not send"),
Self::Encoded => {
out.write_str("described by `content`, which this CLI does not encode")
}
Self::Structured => out.write_str("neither a value nor a list of values"),
Self::Style(style) => {
write!(
out,
"declared with `style: {style}`, which this CLI does not serialise"
)
}
}
}
}
impl Join {
#[must_use]
pub fn note(self) -> &'static str {
match self {
Self::Pairs => "repeatable; each value is sent as its own field",
Self::Commas => "repeatable; the values are sent comma-separated in one field",
}
}
}
impl Shape {
#[must_use]
pub fn repeatable(&self) -> bool {
matches!(self, Self::Flag { join: Some(_), .. })
}
}
#[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("{op}: `{key}` is not a list of names")]
GateList { op: String, key: &'static str },
#[error("{op}: the gate `{gate}` is one of the flags every subcommand already spends")]
ReservedGate { op: String, gate: Gate },
#[error("{op}: the gate `{gate}` is named twice")]
DuplicateGate { op: String, gate: Gate },
#[error(
"{op}: a read stands behind no gate, and this one names `{gate}`; \
mark the operation `x-cli-writes: true` or drop the gate"
)]
GatedRead { op: String, gate: Gate },
#[error(transparent)]
Reference(#[from] RefError),
#[error(
"{op}: parameter `{name}` is {why}, and the document requires it; \
correct the parameter in an Overlay, or drop its `required`"
)]
Parameter {
op: String,
name: String,
why: Unsupported,
},
#[error(
"{op}: `{media_type}` is not a media type; \
an Overlay is where a document's content type is corrected"
)]
MediaType { op: String, media_type: String },
#[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
}
#[must_use]
pub fn gates(&self) -> Vec<&Gate> {
let mut named: Vec<&Gate> = Vec::new();
for gate in self.ops.iter().flat_map(Operation::gates) {
if !named.contains(&gate) {
named.push(gate);
}
}
named
}
pub fn gated_by(&self, gate: &str) -> impl Iterator<Item = &Operation> {
self.ops
.iter()
.filter(move |op| op.gates().iter().any(|named| named.as_str() == gate))
}
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 effect = effect_of(&method, op);
let gates = gates_of(op, id, effect)?;
let mut flags = Namespace::with_reserved(gates.iter().map(Gate::as_str).chain(RESERVED));
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)?;
Ok(Self {
id: id.to_owned(),
group,
command,
method,
path: path.to_owned(),
summary: op.summary.clone(),
description: op.description.clone(),
params,
body,
effect,
gates,
})
}
#[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 gates(&self) -> &[Gate] {
&self.gates
}
#[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 gates_of(op: &openapiv3::Operation, id: &str, effect: Effect) -> Result<Vec<Gate>, LoadError> {
let mut gates: Vec<Gate> = Vec::new();
for raw in listed(op, id, GATES)? {
let gate = Gate::new(GATES, raw)?;
if RESERVED.contains(&gate.as_str()) {
return Err(LoadError::ReservedGate {
op: id.to_owned(),
gate,
});
}
if gates.contains(&gate) {
return Err(LoadError::DuplicateGate {
op: id.to_owned(),
gate,
});
}
gates.push(gate);
}
match (effect, gates.first()) {
(Effect::Read, Some(gate)) => Err(LoadError::GatedRead {
op: id.to_owned(),
gate: gate.clone(),
}),
_ => Ok(gates),
}
}
#[cfg(feature = "document")]
fn listed<'o>(
op: &'o openapiv3::Operation,
id: &str,
key: &'static str,
) -> Result<Vec<&'o str>, LoadError> {
let reject = || LoadError::GateList {
op: id.to_owned(),
key,
};
match op.extensions.get(key) {
None => Ok(Vec::new()),
Some(serde_json::Value::Array(names)) => names
.iter()
.map(|name| name.as_str().ok_or_else(reject))
.collect(),
Some(_) => Err(reject()),
}
}
#[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 data = param.parameter_data_ref();
let shape = shape_of(op, param, data, components, flags)?;
if let Shape::Unreachable(why) = &shape
&& data.required
{
return Err(LoadError::Parameter {
op: op.to_owned(),
name: data.name.clone(),
why: why.clone(),
});
}
Ok(Self {
name: data.name.clone(),
required: data.required,
shape,
description: data.description.clone(),
})
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn shape(&self) -> &Shape {
&self.shape
}
#[must_use]
pub fn required(&self) -> bool {
self.required
}
#[must_use]
pub fn description(&self) -> Option<&str> {
self.description.as_deref()
}
}
#[cfg(feature = "document")]
fn shape_of(
op: &str,
param: &Parameter,
data: &ParameterData,
components: &Components,
flags: &mut Namespace,
) -> Result<Shape, LoadError> {
let Some((location, style)) = sent_in(param) else {
return Ok(Shape::Unreachable(Unsupported::Cookie));
};
let ParameterSchemaOrContent::Schema(schema) = &data.format else {
return Ok(Shape::Unreachable(Unsupported::Encoded));
};
let (scalar, repeats) = if let Some(scalar) = scalar_of(schema, components)? {
(scalar, false)
} else if let Some(scalar) = items_of(schema, components)? {
(scalar, true)
} else {
return Ok(Shape::Unreachable(Unsupported::Structured));
};
runnable(&scalar, op, &data.name)?;
let join = match style {
Ok(join) => join,
Err(style) => return Ok(Shape::Unreachable(Unsupported::Style(style.to_owned()))),
};
Ok(Shape::Flag {
flag: flags.claim(&kebab(&data.name), "param"),
location,
scalar,
join: repeats.then_some(join),
})
}
#[cfg(feature = "document")]
fn sent_in(param: &Parameter) -> Option<(Location, Result<Join, &'static str>)> {
Some(match param {
Parameter::Query {
parameter_data,
style,
..
} => (Location::Query, query_join(style, parameter_data.explode)),
Parameter::Path { style, .. } => (
Location::Path,
match style {
PathStyle::Simple => Ok(Join::Commas),
PathStyle::Matrix => Err("matrix"),
PathStyle::Label => Err("label"),
},
),
Parameter::Header { .. } => (Location::Header, Ok(Join::Commas)),
Parameter::Cookie { .. } => return None,
})
}
#[cfg(feature = "document")]
fn query_join(style: &QueryStyle, explode: Option<bool>) -> Result<Join, &'static str> {
match style {
QueryStyle::Form => Ok(match explode {
Some(false) => Join::Commas,
None | Some(true) => Join::Pairs,
}),
QueryStyle::SpaceDelimited => Err("spaceDelimited"),
QueryStyle::PipeDelimited => Err("pipeDelimited"),
QueryStyle::DeepObject => Err("deepObject"),
}
}
#[cfg(feature = "document")]
fn items_of(
schema: &ReferenceOr<Schema>,
components: &Components,
) -> Result<Option<Scalar>, RefError> {
let schema = resolve_schema(schema, components)?;
let SchemaKind::Type(openapiv3::Type::Array(array)) = &schema.schema_kind else {
return Ok(None);
};
let Some(items) = &array.items else {
return Ok(None);
};
scalar_of(&items.clone().unbox(), components)
}
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_media_type(media_type) {
return Err(LoadError::MediaType {
op: id.to_owned(),
media_type: media_type.clone(),
});
}
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)?;
fields.push(Field {
flag: flags.claim(&kebab(name), "body"),
name: name.clone(),
required: required && object.required.iter().any(|r| r == name),
scalar,
description: description_of(&property, components)?,
});
}
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)
}
}