use std::collections::BTreeSet;
use serde_json::Value;
const HTTP_METHODS: [&str; 8] = [
"get", "put", "post", "delete", "patch", "head", "options", "trace",
];
#[derive(Debug, Clone, Copy, Default)]
pub struct ReducerConfig<'a> {
pub reserved_flags: &'a [&'a str],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Location {
Path,
Query,
Header,
}
#[derive(Debug, Clone)]
pub struct Param {
pub wire: String,
pub flag: String,
pub location: Location,
pub required: bool,
pub description: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Method {
pub name: String,
pub operation_id: Option<String>,
pub summary: Option<String>,
pub http_method: String,
pub path: String,
pub params: Vec<Param>,
pub body: Option<bool>,
}
impl Method {
#[must_use]
pub fn path_params(&self) -> Vec<&Param> {
self.params
.iter()
.filter(|param| param.location == Location::Path)
.collect()
}
}
#[derive(Debug, Clone)]
pub struct Cassette {
pub name: String,
pub description: Option<String>,
pub methods: Vec<Method>,
}
#[derive(Debug, Clone, Default)]
pub struct Surface {
pub cassettes: Vec<Cassette>,
}
impl Surface {
#[must_use]
pub fn is_empty(&self) -> bool {
self.cassettes.is_empty()
}
#[must_use]
pub fn cassette(&self, name: &str) -> Option<&Cassette> {
self.cassettes.iter().find(|c| c.name == name)
}
}
#[must_use]
pub fn reduce(
entry_name: &str,
description: Option<String>,
document: &Value,
reducer: &ReducerConfig<'_>,
) -> Cassette {
Cassette {
name: entry_name.to_owned(),
description,
methods: reduce_methods(document, reducer),
}
}
#[must_use]
pub fn reduce_methods(document: &Value, reducer: &ReducerConfig<'_>) -> Vec<Method> {
let mut methods: Vec<Method> = Vec::new();
let mut taken: BTreeSet<String> = BTreeSet::new();
if let Some(paths) = document.get("paths").and_then(Value::as_object) {
for (path, item) in paths {
methods.extend(methods_of(path, item, document, &mut taken, reducer));
}
}
methods.sort_by(|a, b| a.name.cmp(&b.name));
methods
}
fn methods_of(
path: &str,
item: &Value,
document: &Value,
taken: &mut BTreeSet<String>,
reducer: &ReducerConfig<'_>,
) -> Vec<Method> {
let Some(item) = item.as_object() else {
return Vec::new();
};
let shared = parameters_of(item.get("parameters"), document);
HTTP_METHODS
.iter()
.filter_map(|verb| {
let operation = item.get(*verb)?.as_object()?;
let mut params = shared.clone();
params.extend(parameters_of(operation.get("parameters"), document));
let operation_id = operation
.get("operationId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|id| !id.is_empty())
.map(ToOwned::to_owned);
let raw_name = operation_id
.as_deref()
.map_or_else(|| synthesize_id(verb, path), kebab_case);
Some(Method {
name: unique(raw_name, verb, taken),
operation_id,
summary: text_of(operation.get("summary"))
.or_else(|| text_of(operation.get("description"))),
http_method: verb.to_ascii_uppercase(),
path: path.to_owned(),
params: finish_params(path, params, reducer),
body: operation.get("requestBody").map(body_required),
})
})
.collect()
}
fn body_required(body: &Value) -> bool {
body.get("required")
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn parameters_of(value: Option<&Value>, document: &Value) -> Vec<Param> {
let Some(list) = value.and_then(Value::as_array) else {
return Vec::new();
};
list.iter()
.filter_map(|entry| {
let resolved = match entry.get("$ref").and_then(Value::as_str) {
Some(reference) => resolve(reference, document)?,
None => entry,
};
parameter(resolved)
})
.collect()
}
fn resolve<'a>(reference: &str, document: &'a Value) -> Option<&'a Value> {
let pointer = reference.strip_prefix('#')?;
document.pointer(pointer)
}
fn parameter(value: &Value) -> Option<Param> {
let wire = value.get("name").and_then(Value::as_str)?.trim();
if wire.is_empty() {
return None;
}
let location = match value.get("in").and_then(Value::as_str) {
Some("path") => Location::Path,
Some("query") => Location::Query,
Some("header") => Location::Header,
_ => return None,
};
Some(Param {
wire: wire.to_owned(),
flag: kebab_case(wire),
location,
required: location == Location::Path
|| value
.get("required")
.and_then(Value::as_bool)
.unwrap_or(false),
description: text_of(value.get("description")),
})
}
fn finish_params(path: &str, params: Vec<Param>, reducer: &ReducerConfig<'_>) -> Vec<Param> {
let templated = template_params(path);
let mut ordered: Vec<Param> = Vec::new();
for name in &templated {
if let Some(found) = params
.iter()
.find(|p| p.location == Location::Path && &p.wire == name)
{
ordered.push(found.clone());
} else {
ordered.push(Param {
wire: name.clone(),
flag: kebab_case(name),
location: Location::Path,
required: true,
description: None,
});
}
}
for param in params {
if param.location != Location::Path {
ordered.push(param);
}
}
let mut seen: BTreeSet<String> = BTreeSet::new();
for param in &mut ordered {
let mut base = param.flag.clone();
while reducer.reserved_flags.contains(&base.as_str()) {
base = format!("param-{base}");
}
let mut candidate = base.clone();
let mut suffix = 2;
while reducer.reserved_flags.contains(&candidate.as_str())
|| !seen.insert(candidate.clone())
{
candidate = format!("{base}-{suffix}");
suffix += 1;
}
param.flag = candidate;
}
ordered
}
fn template_params(path: &str) -> Vec<String> {
let mut found = Vec::new();
let mut rest = path;
while let Some(open) = rest.find('{') {
let Some(close) = rest[open..].find('}') else {
break;
};
let name = &rest[open + 1..open + close];
if !name.is_empty() {
found.push(name.to_owned());
}
rest = &rest[open + close + 1..];
}
found
}
fn synthesize_id(verb: &str, path: &str) -> String {
let mut parts = vec![verb.to_ascii_lowercase()];
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
let tail = if segments.len() > 3 && segments[0] == "v1" && segments[1] == "cassettes" {
&segments[3..]
} else {
&segments[..]
};
for segment in tail {
let cleaned: String = segment
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.collect();
if !cleaned.is_empty() {
parts.push(kebab_case(&cleaned));
}
}
if parts.len() == 1 {
return parts.remove(0);
}
parts.join("-")
}
fn unique(name: String, verb: &str, taken: &mut BTreeSet<String>) -> String {
if taken.insert(name.clone()) {
return name;
}
let with_verb = format!("{name}-{verb}");
if taken.insert(with_verb.clone()) {
return with_verb;
}
let mut suffix = 2;
loop {
let candidate = format!("{name}-{suffix}");
if taken.insert(candidate.clone()) {
return candidate;
}
suffix += 1;
}
}
fn text_of(value: Option<&Value>) -> Option<String> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
}
fn kebab_case(raw: &str) -> String {
let chars: Vec<char> = raw.trim().chars().collect();
let mut out = String::with_capacity(chars.len() + 4);
for (index, ¤t) in chars.iter().enumerate() {
if current == '_' || current == ' ' || current == '.' {
if !out.ends_with('-') && !out.is_empty() {
out.push('-');
}
continue;
}
if current == '-' {
if !out.ends_with('-') && !out.is_empty() {
out.push('-');
}
continue;
}
if current.is_ascii_uppercase() && index > 0 {
let previous = chars[index - 1];
let starts_word = previous.is_ascii_lowercase()
|| previous.is_ascii_digit()
|| (previous.is_ascii_uppercase()
&& chars.get(index + 1).is_some_and(char::is_ascii_lowercase));
if starts_word && !out.ends_with('-') && !out.is_empty() {
out.push('-');
}
}
out.extend(current.to_lowercase());
}
out.trim_matches('-').to_owned()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use serde_json::json;
const RESERVED: ReducerConfig<'static> = ReducerConfig {
reserved_flags: &["tapes-url", "body", "help", "verbose"],
};
fn reduce(entry_name: &str, description: Option<String>, document: &Value) -> Cassette {
super::reduce(entry_name, description, document, &RESERVED)
}
fn hello_world() -> Value {
json!({
"openapi": "3.1.0",
"paths": {
"/v1/cassettes/hello-world/hello": {
"get": {
"operationId": "getHello",
"summary": "Greet, and read back every stored row"
},
"post": {
"operationId": "createHello",
"summary": "Write one row to the hello table",
"requestBody": {"required": false}
}
}
}
})
}
#[test]
fn an_operation_id_becomes_a_kebab_case_method() {
let cassette = reduce("hello-world", None, &hello_world());
let names: Vec<&str> = cassette.methods.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names, vec!["create-hello", "get-hello"]);
}
#[test]
fn the_republished_path_is_used_verbatim() {
let cassette = reduce("hello-world", None, &hello_world());
let method = cassette
.methods
.iter()
.find(|m| m.name == "get-hello")
.unwrap();
assert_eq!(method.path, "/v1/cassettes/hello-world/hello");
assert_eq!(method.http_method, "GET");
}
#[test]
fn an_optional_request_body_is_distinguished_from_a_required_one_and_from_none() {
let document = json!({"paths": {"/v1/cassettes/c/thing": {
"post": {"operationId": "a", "requestBody": {"required": true}},
"put": {"operationId": "b", "requestBody": {}},
"get": {"operationId": "c"}
}}});
let cassette = reduce("c", None, &document);
let body = |name: &str| {
cassette
.methods
.iter()
.find(|m| m.name == name)
.unwrap()
.body
};
assert_eq!(body("a"), Some(true));
assert_eq!(body("b"), Some(false));
assert_eq!(body("c"), None);
}
#[test]
fn path_parameters_are_ordered_by_the_template_not_by_the_declaration() {
let document = json!({"paths": {"/v1/cassettes/c/{owner}/reports/{id}": {
"parameters": [
{"name": "id", "in": "path", "required": true},
{"name": "owner", "in": "path", "required": true}
],
"get": {"operationId": "getReport"}
}}});
let cassette = reduce("c", None, &document);
let method = &cassette.methods[0];
let names: Vec<&str> = method
.path_params()
.iter()
.map(|p| p.wire.as_str())
.collect();
assert_eq!(names, vec!["owner", "id"]);
}
#[test]
fn a_templated_segment_with_no_declaration_still_becomes_an_argument() {
let document = json!({"paths": {"/v1/cassettes/c/reports/{id}": {
"get": {"operationId": "getReport"}
}}});
let cassette = reduce("c", None, &document);
assert_eq!(cassette.methods[0].path_params()[0].wire, "id");
assert!(cassette.methods[0].path_params()[0].required);
}
#[test]
fn shared_path_item_parameters_reach_every_operation() {
let document = json!({"paths": {"/v1/cassettes/c/reports": {
"parameters": [{"name": "since", "in": "query"}],
"get": {"operationId": "listReports"},
"post": {"operationId": "createReport"}
}}});
let cassette = reduce("c", None, &document);
for method in &cassette.methods {
assert!(
method.params.iter().any(|p| p.wire == "since"),
"{} lost the shared parameter",
method.name,
);
}
}
#[test]
fn a_shared_parameter_list_is_not_mistaken_for_an_operation() {
let document = json!({"paths": {"/v1/cassettes/c/reports": {
"parameters": [{"name": "since", "in": "query"}],
"summary": "not an operation",
"get": {"operationId": "listReports"}
}}});
let cassette = reduce("c", None, &document);
assert_eq!(cassette.methods.len(), 1);
assert_eq!(cassette.methods[0].name, "list-reports");
}
#[test]
fn a_referenced_parameter_is_resolved_from_components() {
let document = json!({
"components": {"parameters": {"Since": {"name": "since", "in": "query", "required": true}}},
"paths": {"/v1/cassettes/c/reports": {
"get": {"operationId": "listReports", "parameters": [{"$ref": "#/components/parameters/Since"}]}
}}
});
let cassette = reduce("c", None, &document);
let param = &cassette.methods[0].params[0];
assert_eq!(param.wire, "since");
assert!(param.required);
assert_eq!(param.location, Location::Query);
}
#[test]
fn a_reference_that_does_not_resolve_is_dropped_rather_than_guessed_at() {
let document = json!({"paths": {"/v1/cassettes/c/reports": {
"get": {"operationId": "listReports", "parameters": [{"$ref": "#/components/parameters/Absent"}]}
}}});
let cassette = reduce("c", None, &document);
assert!(cassette.methods[0].params.is_empty());
}
#[test]
fn a_cookie_parameter_is_ignored_because_a_cli_cannot_offer_one() {
let document = json!({"paths": {"/v1/cassettes/c/reports": {
"get": {"operationId": "listReports", "parameters": [{"name": "sid", "in": "cookie"}]}
}}});
let cassette = reduce("c", None, &document);
assert!(cassette.methods[0].params.is_empty());
}
#[test]
fn a_parameter_cannot_take_a_flag_the_subcommand_defines_itself() {
let document = json!({"paths": {"/v1/cassettes/c/reports": {
"get": {"operationId": "listReports", "parameters": [
{"name": "tapes_url", "in": "query"},
{"name": "body", "in": "query"}
]}
}}});
let cassette = reduce("c", None, &document);
let flags: Vec<&str> = cassette.methods[0]
.params
.iter()
.map(|p| p.flag.as_str())
.collect();
assert_eq!(flags, vec!["param-tapes-url", "param-body"]);
assert_eq!(cassette.methods[0].params[0].wire, "tapes_url");
}
#[test]
fn a_reserved_rewrite_that_is_itself_reserved_is_rewritten_again() {
let adversarial = ReducerConfig {
reserved_flags: &["body", "param-body", "help"],
};
let document = json!({"paths": {"/v1/cassettes/c/reports": {
"get": {"operationId": "listReports", "parameters": [
{"name": "body", "in": "query"}
]}
}}});
let cassette = super::reduce("c", None, &document, &adversarial);
let param = &cassette.methods[0].params[0];
assert_eq!(param.flag, "param-param-body");
assert_eq!(param.wire, "body");
}
#[test]
fn sibling_rewrites_that_collide_come_out_unique_and_unreserved() {
let adversarial = ReducerConfig {
reserved_flags: &["body", "param-body"],
};
let document = json!({"paths": {"/v1/cassettes/c/reports": {
"get": {"operationId": "listReports", "parameters": [
{"name": "body", "in": "query"},
{"name": "param_body", "in": "query"}
]}
}}});
let cassette = super::reduce("c", None, &document, &adversarial);
let flags: Vec<&str> = cassette.methods[0]
.params
.iter()
.map(|p| p.flag.as_str())
.collect();
assert_eq!(flags, vec!["param-param-body", "param-param-body-2"]);
for flag in flags {
assert!(
!adversarial.reserved_flags.contains(&flag),
"{flag:?} is still reserved",
);
}
}
#[test]
fn a_uniqueness_suffix_may_not_land_on_a_reserved_name() {
let adversarial = ReducerConfig {
reserved_flags: &["since-id-2"],
};
let document = json!({"paths": {"/v1/cassettes/c/reports": {
"get": {"operationId": "listReports", "parameters": [
{"name": "since_id", "in": "query"},
{"name": "sinceId", "in": "header"}
]}
}}});
let cassette = super::reduce("c", None, &document, &adversarial);
let flags: Vec<&str> = cassette.methods[0]
.params
.iter()
.map(|p| p.flag.as_str())
.collect();
assert_eq!(flags, vec!["since-id", "since-id-3"]);
}
#[test]
fn two_parameters_that_kebab_to_the_same_flag_stay_distinguishable() {
let document = json!({"paths": {"/v1/cassettes/c/reports": {
"get": {"operationId": "listReports", "parameters": [
{"name": "since_id", "in": "query"},
{"name": "sinceId", "in": "header"}
]}
}}});
let cassette = reduce("c", None, &document);
let flags: Vec<&str> = cassette.methods[0]
.params
.iter()
.map(|p| p.flag.as_str())
.collect();
assert_eq!(flags, vec!["since-id", "since-id-2"]);
}
#[test]
fn an_operation_without_an_id_gets_one_from_its_verb_and_path() {
let document = json!({"paths": {"/v1/cassettes/summary/reports/{id}": {"get": {}}}});
let cassette = reduce("summary", None, &document);
assert_eq!(cassette.methods[0].name, "get-reports-id");
}
#[test]
fn colliding_method_names_are_disambiguated_by_verb() {
let document = json!({"paths": {"/v1/cassettes/c/thing": {
"get": {"operationId": "doThing"},
"post": {"operationId": "do_thing"}
}}});
let cassette = reduce("c", None, &document);
let names: Vec<&str> = cassette.methods.iter().map(|m| m.name.as_str()).collect();
assert_eq!(names.len(), 2);
assert!(names.contains(&"do-thing"), "got: {names:?}");
assert!(
names.iter().any(|n| n.starts_with("do-thing-")),
"got: {names:?}",
);
}
#[test]
fn a_document_with_nothing_usable_yields_a_cassette_with_no_methods() {
for document in [
json!({}),
json!({"paths": {}}),
json!({"paths": "nonsense"}),
] {
assert!(reduce("c", None, &document).methods.is_empty());
}
}
#[test]
fn the_generated_surface_is_stable_between_reductions() {
let first = reduce("hello-world", None, &hello_world());
let second = reduce("hello-world", None, &hello_world());
let names =
|c: &Cassette| -> Vec<String> { c.methods.iter().map(|m| m.name.clone()).collect() };
assert_eq!(names(&first), names(&second));
}
#[test]
fn kebab_casing_keeps_acronyms_whole() {
assert_eq!(kebab_case("getHello"), "get-hello");
assert_eq!(kebab_case("since_id"), "since-id");
assert_eq!(kebab_case("getHTTPStatus"), "get-http-status");
assert_eq!(kebab_case("already-kebab"), "already-kebab");
assert_eq!(kebab_case("X"), "x");
}
}