#![expect(
clippy::expect_used,
clippy::unwrap_used,
reason = "a test that cannot build its fixture should fail loudly and name it"
)]
use typed_openapi::model::Body;
use typed_openapi::{
Document, Effect, Invocation, Operation, Param, Shape, Unsupported, Values, render, tree,
};
const TOY: &str = include_str!("fixtures/toy.yaml");
const CORRECTIONS: &str = include_str!("fixtures/corrections.yaml");
const CLI: &str = include_str!("fixtures/cli.yaml");
const OVERLAYS: &[&str] = &[CORRECTIONS, CLI];
fn document() -> Document {
Document::load(TOY, OVERLAYS).expect("the vendor's document plus the adopter's Overlay")
}
#[test]
fn the_overlay_adds_what_the_vendor_left_out() {
let doc = document();
assert!(doc.get("archiveVoucher").is_some(), "the added operation");
let Body::JsonFields(fields) = doc.get("createVoucher").unwrap().body() else {
panic!("createVoucher takes a flat JSON body");
};
assert!(
fields.iter().any(|f| f.name() == "internal_ref"),
"the added field"
);
}
#[test]
fn the_gate_is_default_closed_and_the_marker_can_only_add_writes() {
let doc = document();
let effect = |id: &str| doc.get(id).unwrap_or_else(|| panic!("{id}")).effect();
assert_eq!(effect("listVouchers"), Effect::Read);
assert_eq!(effect("getVoucher"), Effect::Read);
assert_eq!(effect("createVoucher"), Effect::Write);
assert_eq!(effect("updateVoucher"), Effect::Write);
assert_eq!(effect("enshrineVoucher"), Effect::Write);
assert_eq!(effect("archiveVoucher"), Effect::Write);
assert_eq!(effect("renderVoucher"), Effect::Write);
}
#[test]
fn the_gates_an_operation_names_come_back_off_the_document_and_the_blob() {
let doc = document();
let gates = |doc: &Document, id: &str| -> Vec<String> {
doc.get(id)
.unwrap_or_else(|| panic!("{id}"))
.gates()
.iter()
.map(|gate| gate.as_str().to_owned())
.collect()
};
assert_eq!(gates(&doc, "enshrineVoucher"), ["enshrine"]);
assert_eq!(gates(&doc, "sendVoucherByEmail"), ["email"]);
assert!(
gates(&doc, "createVoucher").is_empty(),
"a write the document names no hazard on stands behind --commit alone"
);
assert!(
gates(&doc, "getVoucher").is_empty(),
"a read is asked nothing"
);
let blob = doc.to_blob().expect("the reduction encodes");
let shipped = Document::from_blob(&blob).expect("and decodes");
assert_eq!(gates(&shipped, "enshrineVoucher"), ["enshrine"]);
assert_eq!(gates(&shipped, "sendVoucherByEmail"), ["email"]);
}
#[test]
fn the_document_names_its_gates_and_what_stands_behind_each() {
let doc = document();
let named: Vec<&str> = doc.gates().iter().map(|gate| gate.as_str()).collect();
assert_eq!(named, ["enshrine", "email"]);
let behind = |gate: &str| -> Vec<&str> { doc.gated_by(gate).map(Operation::id).collect() };
assert_eq!(behind("enshrine"), ["enshrineVoucher"]);
assert_eq!(behind("email"), ["sendVoucherByEmail"]);
assert!(
behind("commit").is_empty(),
"the write gate is not one of the named ones"
);
}
#[test]
fn every_body_is_exactly_one_flag_set() {
let doc = document();
let body = |id: &str| doc.get(id).unwrap_or_else(|| panic!("{id}")).body().clone();
assert!(matches!(body("getVoucher"), Body::None));
assert!(matches!(body("createVoucher"), Body::JsonFields(_)));
assert!(matches!(
body("createContact"),
Body::JsonWhole { required: true }
));
assert!(matches!(
body("uploadDocument"),
Body::Opaque { ref media_type, .. } if media_type == "application/pdf"
));
assert!(matches!(
body("uploadDocumentMultipart"),
Body::Multipart { .. }
));
}
#[test]
fn a_body_field_moves_aside_for_a_path_parameter_of_the_same_name() {
let doc = document();
let update = doc.get("updateVoucher").unwrap();
assert_eq!(flag_of(update.param("id").unwrap()), "id");
let Body::JsonFields(fields) = update.body() else {
panic!("updateVoucher takes a flat JSON body");
};
let id = fields.iter().find(|f| f.name() == "id").unwrap();
assert_eq!(id.flag(), "body-id");
assert!(id.renamed(), "and it says so in its help line");
}
#[test]
fn the_whole_mounted_tree_is_a_valid_clap_command() {
let doc = document();
clap::Command::new("toy")
.subcommand(clap::Command::new("raw").subcommands(tree::commands(&doc)))
.debug_assert();
}
#[test]
fn a_dry_run_prints_the_request_that_commit_would_send() {
let doc = document();
let op = doc.get("updateVoucher").unwrap();
let values = Values::new()
.param("id", 5)
.json(serde_json::json!({"total": "12.50"}));
let request = Invocation::new(op, values)
.expect("the values satisfy the operation")
.request(doc.base())
.expect("the base URL is a URL");
assert_eq!(
render(&request),
"PUT /vouchers/5 HTTP/1.1\n\
host: localhost:9999\n\
content-type: application/json\n\
\n\
{\"total\":\"12.50\"}\n"
);
}
#[test]
fn the_document_rejects_values_it_does_not_describe() {
let doc = document();
let op = doc.get("getVoucher").unwrap();
assert!(
Invocation::new(op, Values::new()).is_err(),
"`id` is required"
);
assert!(
Invocation::new(op, Values::new().param("nope", 1)).is_err(),
"there is no `nope` parameter"
);
assert!(
Invocation::new(op, Values::new().param("id", "five")).is_err(),
"`id` is an integer"
);
assert!(
Invocation::new(op, Values::new().param("id", 5).json(serde_json::json!({}))).is_err(),
"getVoucher takes no body"
);
}
#[test]
fn a_path_value_cannot_smuggle_a_segment_into_the_url() {
let doc = document();
let op = doc.get("archiveVoucher").unwrap();
let refused = Invocation::new(op, Values::new().param("id", "1/../../etc"))
.expect_err("`id` is `type: integer` in the document");
assert!(
refused.to_string().contains("is not an integer"),
"{refused}"
);
let doc = Document::load(
&TOY.replace(
" schema:\n type: integer\n format: int64",
" schema:\n type: string",
),
OVERLAYS,
)
.expect("a document whose ids are strings");
let op = doc.get("getVoucher").unwrap();
let request = Invocation::new(op, Values::new().param("id", "1/../../etc"))
.unwrap()
.request(doc.base())
.unwrap();
assert_eq!(request.uri().path(), "/vouchers/1%2F..%2F..%2Fetc");
}
#[test]
fn a_rule_a_named_schema_states_reaches_the_property_pointing_at_it() {
let doc = document();
let Body::JsonFields(fields) = doc.get("updateVoucher").unwrap().body() else {
panic!("updateVoucher takes a flat JSON body");
};
let total = fields.iter().find(|f| f.name() == "total").unwrap();
assert_eq!(
total.scalar().note().as_deref(),
Some(r"matches ^-?[0-9]+(\.[0-9]{1,2})?$"),
"the rule the `Money` schema states did not reach `total`"
);
assert_eq!(
total
.scalar()
.parse("1,50")
.expect_err("a comma is not a decimal point")
.to_string(),
r"`1,50` does not match ^-?[0-9]+(\.[0-9]{1,2})?$"
);
assert!(total.scalar().parse("12.50").is_ok());
}
#[test]
fn every_rule_a_parameter_states_is_checked_because_nothing_else_can_check_it() {
const PARAMETERS: &str = " /vouchers:\n\
\x20 get:\n\
\x20 operationId: listVouchers\n\
\x20 parameters:\n\
\x20 - { name: since, in: query, schema: { type: string, pattern: '^[0-9]{4}-[0-9]{2}$' } }\n\
\x20 - { name: code, in: query, schema: { type: string, minLength: 3, maxLength: 3 } }\n\
\x20 - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, multipleOf: 5 } }\n\
\x20 responses: { \"200\": { description: OK } }\n";
let doc = Document::load(&synthetic(PARAMETERS), &[]).expect("a document");
let op = doc.get("listVouchers").unwrap();
let refused = |name: &str, value: &str| {
Invocation::new(op, Values::new().param(name, value))
.expect_err("the document refuses it")
.to_string()
};
assert_eq!(
refused("since", "2026-9"),
"listVouchers: `since`: `2026-9` does not match ^[0-9]{4}-[0-9]{2}$"
);
assert_eq!(
refused("code", "EU"),
"listVouchers: `code`: `EU` is shorter than 3 characters"
);
assert_eq!(
refused("code", "EURO"),
"listVouchers: `code`: `EURO` is longer than 3 characters"
);
assert_eq!(
refused("limit", "0"),
"listVouchers: `limit`: `0` is not at least 1"
);
assert_eq!(
refused("limit", "105"),
"listVouchers: `limit`: `105` is not at most 100"
);
assert_eq!(
refused("limit", "7"),
"listVouchers: `limit`: `7` is not a multiple of 5"
);
assert!(
Invocation::new(
op,
Values::new()
.param("since", "2026-09")
.param("code", "EUR")
.param("limit", "25"),
)
.is_ok()
);
}
#[test]
fn a_pattern_no_engine_can_read_is_refused_while_the_document_is_reduced() {
let error = Document::load(
&synthetic(
" /vouchers:\n\
\x20 get:\n\
\x20 operationId: listVouchers\n\
\x20 parameters:\n\
\x20 - { name: since, in: query, schema: { type: string, pattern: '[unterminated' } }\n\
\x20 responses: { \"200\": { description: OK } }\n",
),
&[],
)
.expect_err("`[unterminated` is not a regular expression");
assert_eq!(
error.to_string(),
"listVouchers: `since`: `[unterminated` is not a regular expression: Unbalanced bracket"
);
}
fn upload(content_key: &str) -> String {
synthetic(&format!(
" /documents:\n\
\x20 post:\n\
\x20 operationId: uploadDocument\n\
\x20 requestBody:\n\
\x20 required: true\n\
\x20 content:\n\
\x20 '{content_key}':\n\
\x20 schema: {{ type: string, format: binary }}\n\
\x20 responses: {{ \"201\": {{ description: Created }} }}\n"
))
}
#[test]
fn a_content_key_that_is_not_a_media_type_is_refused_while_the_document_is_reduced() {
const REPAIR: &str = "overlay: 1.1.0\n\
info: { title: t, version: \"1\" }\n\
actions:\n\
\x20 - target: \"$.paths['/documents'].post.requestBody.content['form-data']\"\n\
\x20 description: The vendor means `multipart/form-data`.\n\
\x20 remove: true\n\
\x20 - target: $.paths['/documents'].post.requestBody.content\n\
\x20 description: Say it the way the wire spells it.\n\
\x20 update:\n\
\x20 multipart/form-data:\n\
\x20 schema: { type: object, properties: { file: { type: string } } }\n";
let error =
Document::load(&upload("form-data"), &[]).expect_err("`form-data` names no media type");
assert_eq!(
error.to_string(),
"uploadDocument: `form-data` is not a media type; \
an Overlay is where a document's content type is corrected"
);
let repaired = Document::load(&upload("form-data"), &[REPAIR]).expect("the Overlay repairs it");
assert!(matches!(
repaired
.get("uploadDocument")
.expect("the operation")
.body(),
Body::Multipart { .. }
));
}
#[test]
fn a_media_type_this_crate_cannot_assemble_is_carried_rather_than_refused() {
let body = |key: &str| {
Document::load(&upload(key), &[])
.unwrap_or_else(|error| panic!("{key}: {error}"))
.get("uploadDocument")
.expect("the operation")
.body()
.clone()
};
assert!(matches!(
body("application/pdf"),
Body::Opaque { ref media_type, .. } if media_type == "application/pdf"
));
assert!(matches!(
body("text/csv; charset=utf-8"),
Body::Opaque { ref media_type, .. } if media_type == "text/csv; charset=utf-8"
));
assert!(matches!(
body("application/x-www-form-urlencoded"),
Body::Opaque { .. }
));
}
fn pointing(properties: &str) -> String {
format!(
"openapi: 3.0.3\n\
info: {{ title: t, version: \"1\" }}\n\
servers: [{{ url: 'http://localhost:9999' }}]\n\
paths:\n\
\x20 /notes:\n\
\x20 post:\n\
\x20 operationId: createNote\n\
\x20 requestBody:\n\
\x20 required: true\n\
\x20 content:\n\
\x20 application/json:\n\
\x20 schema:\n\
\x20 type: object\n\
\x20 properties:\n\
{properties}\
\x20 responses: {{ \"201\": {{ description: Created }} }}\n\
components:\n\
\x20 schemas:\n\
\x20 Day:\n\
\x20 type: string\n\
\x20 description: A calendar day.\n\
\x20 pattern: '^[0-9]{{4}}-[0-9]{{2}}-[0-9]{{2}}$'\n"
)
}
#[test]
fn a_single_element_all_of_is_read_as_the_schema_it_wraps() {
let doc = Document::load(
&pointing(
"\x20 booked:\n\
\x20 allOf: [{ $ref: '#/components/schemas/Day' }]\n\
\x20 description: The day this note is booked under.\n\
\x20 due:\n\
\x20 allOf: [{ $ref: '#/components/schemas/Day' }]\n",
),
&[],
)
.expect("a document");
let Body::JsonFields(fields) = doc.get("createNote").unwrap().body() else {
panic!("both properties are scalars, so both are flags");
};
let field = |name: &str| {
fields
.iter()
.find(|f| f.name() == name)
.unwrap_or_else(|| panic!("{name}"))
};
assert_eq!(
field("booked").scalar().note().as_deref(),
Some(r"matches ^[0-9]{4}-[0-9]{2}-[0-9]{2}$")
);
assert!(field("booked").scalar().parse("2026-09-14").is_ok());
assert!(field("booked").scalar().parse("14.09.2026").is_err());
assert_eq!(
field("booked").description(),
Some("The day this note is booked under.")
);
assert_eq!(field("due").description(), Some("A calendar day."));
}
#[test]
fn an_all_of_that_is_more_than_a_wrapper_is_not_a_scalar() {
let whole = |properties: &str| {
let doc = Document::load(&pointing(properties), &[]).expect("a document");
matches!(
doc.get("createNote").expect("createNote").body(),
Body::JsonWhole { required: true }
)
};
assert!(whole(
"\x20 booked:\n\
\x20 allOf:\n\
\x20 - { $ref: '#/components/schemas/Day' }\n\
\x20 - { type: string, minLength: 1 }\n"
));
assert!(whole(
"\x20 booked:\n\
\x20 type: string\n\
\x20 allOf: [{ $ref: '#/components/schemas/Day' }]\n"
));
}
const LISTED: &str = r" /vouchers:
get:
operationId: listVouchers
parameters:
- name: tag
in: query
explode: true
schema: { type: array, items: { type: string } }
responses: { '200': { description: OK } }
";
const SHAPES: &str = r" /vouchers:
get:
operationId: listVouchers
parameters:
- name: tag
in: query
schema: { type: array, items: { type: string } }
- name: filter
in: query
required: false
schema:
type: object
properties:
opened:
type: object
properties:
from: { type: string }
- name: limit
in: query
schema: { type: integer }
responses: { '200': { description: OK } }
/contacts:
post:
operationId: createContact
responses: { '201': { description: OK } }
";
fn sent(document: &str, id: &str, values: Values) -> String {
let doc = Document::load(document, &[]).expect("a document");
let op = doc.get(id).unwrap_or_else(|| panic!("{id}"));
Invocation::new(op, values)
.expect("the values satisfy the operation")
.request(doc.base())
.expect("the base URL is a URL")
.uri()
.to_string()
}
#[test]
fn a_list_parameter_reaches_the_query_the_way_the_document_explodes_it() {
let both = |document: &str| {
sent(
document,
"listVouchers",
Values::new().each("tag", ["a", "b"]),
)
};
assert_eq!(
both(&synthetic(LISTED)),
"http://localhost:9999/vouchers?tag=a&tag=b"
);
assert_eq!(
both(&synthetic(&LISTED.replace(" explode: true\n", ""))),
"http://localhost:9999/vouchers?tag=a&tag=b"
);
assert_eq!(
both(&synthetic(
&LISTED.replace("explode: true", "explode: false")
)),
"http://localhost:9999/vouchers?tag=a,b"
);
}
#[test]
fn a_comma_inside_a_value_is_not_the_comma_between_two_values() {
assert_eq!(
sent(
&synthetic(&LISTED.replace("explode: true", "explode: false")),
"listVouchers",
Values::new().each("tag", ["a,b", "c"]),
),
"http://localhost:9999/vouchers?tag=a%2Cb,c"
);
}
#[test]
fn a_parameter_no_flag_can_carry_leaves_every_other_operation_standing() {
let doc = Document::load(&synthetic(SHAPES), &[])
.expect("an unreachable parameter does not stop the document reducing");
let list = doc.get("listVouchers").expect("listVouchers");
assert!(
matches!(
list.param("filter")
.expect("it is in the reduction")
.shape(),
Shape::Unreachable(Unsupported::Structured)
),
"an object parameter is carried, not dropped and not refused"
);
assert_eq!(flag_of(list.param("limit").expect("limit")), "limit");
assert_eq!(
sent(
&synthetic(SHAPES),
"listVouchers",
Values::new().each("tag", ["a"]).param("limit", 5),
),
"http://localhost:9999/vouchers?tag=a&limit=5"
);
assert!(doc.get("createContact").is_some(), "the other operation");
let refused = Invocation::new(list, Values::new().param("filter", "{}"))
.expect_err("there is nowhere to put it");
assert_eq!(
refused.to_string(),
"listVouchers: `filter` is neither a value nor a list of values, \
so there is nowhere in the request to put a value for it"
);
}
#[test]
fn a_required_parameter_no_flag_can_carry_names_itself_and_the_way_out() {
let error = Document::load(
&synthetic(&SHAPES.replace("required: false", "required: true")),
&[],
)
.expect_err("a required parameter with no flag");
assert_eq!(
error.to_string(),
"listVouchers: parameter `filter` is neither a value nor a list of values, \
and the document requires it; correct the parameter in an Overlay, \
or drop its `required`"
);
}
#[test]
fn a_cookie_and_a_content_parameter_are_carried_the_way_an_object_is() {
const NEIGHBOURS: &str = r" /vouchers:
get:
operationId: listVouchers
parameters:
- name: session
in: cookie
schema: { type: string }
- name: window
in: query
content:
application/json:
schema: { type: object }
responses: { '200': { description: OK } }
";
let doc = Document::load(&synthetic(NEIGHBOURS), &[]).expect("the document still reduces");
let op = doc.get("listVouchers").expect("listVouchers");
let why = |name: &str| match op.param(name).expect("it is in the reduction").shape() {
Shape::Unreachable(why) => why.clone(),
Shape::Flag { .. } => panic!("`{name}` has no command-line spelling"),
};
assert_eq!(why("session"), Unsupported::Cookie);
assert_eq!(why("window"), Unsupported::Encoded);
let command = tree::command(op);
let longs: Vec<&str> = command
.get_arguments()
.filter_map(clap::Arg::get_long)
.collect();
assert!(
!longs.contains(&"session") && !longs.contains(&"window"),
"{longs:?}"
);
let long_about = command.get_long_about().expect("a long help").to_string();
assert!(
long_about
.contains("`session` has no flag: it is `in: cookie`, which this CLI does not send."),
"{long_about}"
);
assert!(
long_about.contains(
"`window` has no flag: it is described by `content`, which this CLI does not encode."
),
"{long_about}"
);
}
#[test]
fn a_style_this_crate_does_not_serialise_names_itself() {
const STYLED: &str = r" /vouchers:
get:
operationId: listVouchers
parameters:
- name: tag
in: query
required: false
style: pipeDelimited
schema: { type: array, items: { type: string } }
responses: { '200': { description: OK } }
";
const SEGMENTED: &str = r" /vouchers/{ids}:
get:
operationId: getVouchers
parameters:
- name: ids
in: path
required: true
style: matrix
schema: { type: array, items: { type: integer } }
responses: { '200': { description: OK } }
";
for style in ["spaceDelimited", "pipeDelimited", "deepObject"] {
let document = synthetic(&STYLED.replace("pipeDelimited", style));
let doc = Document::load(&document, &[]).expect("the document still reduces");
let op = doc.get("listVouchers").expect("listVouchers");
let Shape::Unreachable(why) = op.param("tag").expect("tag").shape() else {
panic!("`{style}` is not a serialisation this crate writes");
};
assert_eq!(
why.to_string(),
format!("declared with `style: {style}`, which this CLI does not serialise")
);
let error = Document::load(&document.replace("required: false", "required: true"), &[])
.expect_err("a required parameter with no flag");
assert!(
error.to_string().contains(&format!("`style: {style}`")),
"{error}"
);
}
for style in ["matrix", "label"] {
for schema in [
"{ type: integer }",
"{ type: array, items: { type: integer } }",
] {
let document = synthetic(
&SEGMENTED
.replace("matrix", style)
.replace("{ type: array, items: { type: integer } }", schema),
);
let error =
Document::load(&document, &[]).expect_err("a path style this crate does not write");
assert!(
error.to_string().contains(&format!("`style: {style}`")),
"{schema}: {error}"
);
}
}
}
#[test]
fn a_list_in_a_path_segment_or_a_header_is_comma_separated() {
const SEGMENTED: &str = r" /vouchers/{ids}:
get:
operationId: getVouchers
parameters:
- name: ids
in: path
required: true
schema: { type: array, items: { type: integer } }
- name: X-Trace
in: header
schema: { type: array, items: { type: string } }
responses: { '200': { description: OK } }
";
let doc = Document::load(&synthetic(SEGMENTED), &[]).expect("a document");
let op = doc.get("getVouchers").expect("getVouchers");
let request = Invocation::new(
op,
Values::new()
.each("ids", [3, 4, 5])
.each("X-Trace", ["one", "two"]),
)
.expect("the values satisfy the operation")
.request(doc.base())
.expect("the base URL is a URL");
assert_eq!(request.uri().path(), "/vouchers/3,4,5");
assert_eq!(
request.headers().get("x-trace").expect("the header"),
"one,two"
);
}
#[test]
fn a_parameter_that_is_not_a_list_is_refused_a_second_value() {
let doc = Document::load(&synthetic(SHAPES), &[]).expect("a document");
let op = doc.get("listVouchers").expect("listVouchers");
let refused = Invocation::new(op, Values::new().each("limit", [5, 6]))
.expect_err("`limit` is one integer");
assert_eq!(
refused.to_string(),
"listVouchers: `limit` takes one value, and was given 2"
);
}
#[test]
fn a_repeatable_flag_says_what_it_does_and_reaches_the_request_builder_repeated() {
let doc = Document::load(&synthetic(SHAPES), &[]).expect("a document");
let op = doc.get("listVouchers").expect("listVouchers");
let command = tree::command(op);
let tag = command
.get_arguments()
.find(|arg| arg.get_long() == Some("tag"))
.expect("--tag");
assert!(matches!(tag.get_action(), clap::ArgAction::Append));
let help = tag.get_help().expect("a help line").to_string();
assert!(
help.contains("repeatable; each value is sent as its own field"),
"{help}"
);
let matches = clap::Command::new("toy")
.subcommands(tree::commands(&doc))
.get_matches_from(["toy", "vouchers", "list", "--tag", "a", "--tag", "b"]);
let selected = tree::select(&doc, &matches).expect("the subcommand names an operation");
let request = Invocation::new(selected.operation(), selected.values().clone())
.expect("the flags satisfy the operation")
.request(doc.base())
.expect("the base URL is a URL");
assert_eq!(request.uri().query(), Some("tag=a&tag=b"));
}
#[test]
fn a_reduction_survives_the_round_trip_the_bless_step_makes() {
let doc = document();
let blob = doc.to_blob().expect("the reduction encodes");
assert_eq!(Document::from_blob(&blob).expect("and decodes"), doc);
}
#[test]
fn a_blob_that_is_not_a_reduction_is_refused_by_name() {
let error = Document::from_blob(b"not a reduction").expect_err("not a reduction");
assert!(error.to_string().contains("reduced model"), "{error}");
}
fn flag_of(param: &Param) -> &str {
match param.shape() {
Shape::Flag { flag, .. } => flag,
Shape::Unreachable(why) => panic!("`{}` has no flag: it is {why}", param.name()),
}
}
fn synthetic(paths: &str) -> String {
format!(
"openapi: 3.0.3\n\
info: {{ title: t, version: \"1\" }}\n\
servers: [{{ url: 'http://localhost:9999' }}]\n\
paths:\n{paths}"
)
}
fn placements(document: &str) -> Vec<String> {
Document::load(document, &[])
.expect("a document")
.iter()
.map(|op| format!("{} {}", op.group(), op.command()))
.collect()
}
#[test]
fn a_prefix_every_path_shares_is_not_the_group() {
let placed = placements(&synthetic(
" /v1/vouchers:\n\
\x20 get: { operationId: listVouchers, responses: { \"200\": { description: OK } } }\n\
\x20 /v1/contacts:\n\
\x20 post: { operationId: createContact, responses: { \"201\": { description: OK } } }\n",
));
assert_eq!(placed, ["vouchers list", "contacts create"]);
}
#[test]
fn two_operations_under_one_name_are_refused_by_both_ids() {
const COLLIDING: &str = " /vouchers/{id}/render:\n\
\x20 get: { operationId: renderVoucher, responses: { \"200\": { description: OK } } }\n\
\x20 /vouchers/{id}/pdf/render:\n\
\x20 get: { operationId: renderVoucherPdf, responses: { \"200\": { description: OK } } }\n";
let error = Document::load(&synthetic(COLLIDING), &[]).expect_err("both are `vouchers render`");
assert_eq!(
error.to_string(),
"`renderVoucher` and `renderVoucherPdf` are both `vouchers render` on the \
command line; give one of them an `x-cli-command`"
);
let resolved = COLLIDING.replace(
"operationId: renderVoucherPdf",
"operationId: renderVoucherPdf, x-cli-command: render-pdf",
);
assert_eq!(
placements(&synthetic(&resolved)),
["vouchers render", "vouchers render-pdf"]
);
}
#[test]
fn the_document_may_name_its_own_group_and_command() {
let placed = placements(&synthetic(
" /vouchers/{id}/render:\n\
\x20 get:\n\
\x20 operationId: renderVoucher\n\
\x20 x-cli-group: reports\n\
\x20 x-cli-command: pdf\n\
\x20 responses: { \"200\": { description: OK } }\n\
\x20 /contacts:\n\
\x20 post: { operationId: createContact, responses: { \"201\": { description: OK } } }\n",
));
assert_eq!(placed, ["reports pdf", "contacts create"]);
}
#[test]
fn a_marker_that_is_not_a_name_is_refused() {
let error = Document::load(
&synthetic(
" /vouchers:\n\
\x20 get:\n\
\x20 operationId: listVouchers\n\
\x20 x-cli-command: [a, b]\n\
\x20 responses: { \"200\": { description: OK } }\n",
),
&[],
)
.expect_err("a list is not a name");
assert_eq!(
error.to_string(),
"listVouchers: `x-cli-command` is not a string"
);
}
#[test]
fn a_gate_the_command_line_cannot_offer_is_refused_by_name() {
let refused = |method: &str, marker: &str| {
let paths = format!(
" /vouchers/{{id}}/enshrine:\n\
\x20 {method}:\n\
\x20 operationId: enshrineVoucher\n\
\x20 x-cli-gates: {marker}\n\
\x20 responses: {{ \"200\": {{ description: OK }} }}\n"
);
Document::load(&synthetic(&paths), &[])
.expect_err("the document names a gate it cannot offer")
.to_string()
};
assert_eq!(
refused("post", "enshrine"),
"enshrineVoucher: `x-cli-gates` is not a list of names"
);
assert_eq!(
refused("post", "[3]"),
"enshrineVoucher: `x-cli-gates` is not a list of names"
);
assert_eq!(
refused("post", "[\"???\"]"),
"the x-cli-gates `???` does not kebab-case into [a-z0-9-]"
);
assert_eq!(
refused("post", "[commit]"),
"enshrineVoucher: the gate `commit` is one of the flags every subcommand \
already spends"
);
assert_eq!(
refused("post", "[enshrine, enshrine]"),
"enshrineVoucher: the gate `enshrine` is named twice"
);
assert_eq!(
refused("get", "[enshrine]"),
"enshrineVoucher: a read stands behind no gate, and this one names \
`enshrine`; mark the operation `x-cli-writes: true` or drop the gate"
);
}
#[test]
fn a_body_field_that_collides_with_a_gate_moves_aside() {
let doc = Document::load(
&synthetic(
" /vouchers/{id}/enshrine:\n\
\x20 post:\n\
\x20 operationId: enshrineVoucher\n\
\x20 x-cli-gates: [enshrine]\n\
\x20 requestBody:\n\
\x20 content:\n\
\x20 application/json:\n\
\x20 schema:\n\
\x20 type: object\n\
\x20 properties:\n\
\x20 enshrine: { type: string }\n\
\x20 responses: { \"200\": { description: OK } }\n",
),
&[],
)
.expect("a document whose body field is spelled like its gate");
let op = doc.get("enshrineVoucher").unwrap();
let Body::JsonFields(fields) = op.body() else {
panic!("enshrineVoucher takes a flat JSON body");
};
let field = fields.iter().find(|f| f.name() == "enshrine").unwrap();
assert_eq!(field.flag(), "body-enshrine");
assert!(field.renamed(), "and it says so in its help line");
tree::command(op).debug_assert();
}
#[test]
fn an_override_that_is_not_spellable_names_itself() {
let error = Document::load(
&synthetic(
" /vouchers:\n\
\x20 get:\n\
\x20 operationId: listVouchers\n\
\x20 x-cli-group: \"???\"\n\
\x20 responses: { \"200\": { description: OK } }\n",
),
&[],
)
.expect_err("`???` is not a name");
assert_eq!(
error.to_string(),
"the x-cli-group `???` does not kebab-case into [a-z0-9-]"
);
}