pub mod diff;
#[cfg(test)]
mod diff_tests;
use std::collections::BTreeMap;
use serde_json::{Value, json};
use crate::dialect::{Document, Info, Operation, unknown_method};
const HTTP_METHODS: &[&str] = &[
"GET", "PUT", "POST", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE",
];
pub fn document(info: &Info, ops: &[Operation]) -> Document {
let mut conflicts = Vec::new();
let mut paths: BTreeMap<String, serde_json::Map<String, Value>> = BTreeMap::new();
let mut components: BTreeMap<String, Value> = BTreeMap::new();
for op in ops {
if !HTTP_METHODS.contains(&op.method.to_ascii_uppercase().as_str()) {
conflicts.push(unknown_method(op, "OpenAPI", "GET, POST, PUT, DELETE, ..."));
continue;
}
for (name, schema) in &op.shape.components {
if let Some(existing) = components.get(name)
&& existing != schema
{
conflicts.push(format!(
"component `{name}` has two different definitions \
(second seen via operation `{}`)",
op.operation_id
));
continue;
}
components.insert(name.clone(), schema.clone());
}
let verb = op.method.to_ascii_lowercase();
let item = paths.entry(op.path.clone()).or_default();
if item.contains_key(&verb) {
conflicts.push(format!(
"{} {} is declared by more than one operation (`{}`)",
op.method, op.path, op.operation_id
));
continue;
}
item.insert(verb, operation_object(op));
}
let mut doc = serde_json::Map::new();
doc.insert("openapi".into(), json!("3.1.0"));
doc.insert("info".into(), info_object(info));
if !info.servers.is_empty() {
let servers: Vec<Value> = info.servers.iter().map(|u| json!({ "url": u })).collect();
doc.insert("servers".into(), json!(servers));
}
let path_items: serde_json::Map<String, Value> = paths
.into_iter()
.map(|(p, item)| (p, Value::Object(item)))
.collect();
doc.insert("paths".into(), Value::Object(path_items));
if !components.is_empty() {
let schemas: serde_json::Map<String, Value> = components.into_iter().collect();
doc.insert("components".into(), json!({ "schemas": schemas }));
}
Document {
value: Value::Object(doc),
conflicts,
notes: Vec::new(),
}
}
fn info_object(info: &Info) -> Value {
let mut m = serde_json::Map::new();
m.insert("title".into(), json!(info.title));
m.insert("version".into(), json!(info.version));
if let Some(d) = &info.description {
m.insert("description".into(), json!(d));
}
Value::Object(m)
}
fn operation_object(op: &Operation) -> Value {
let mut m = serde_json::Map::new();
m.insert("operationId".into(), json!(op.operation_id));
if let Some(s) = &op.summary {
m.insert("summary".into(), json!(s));
}
if !op.shape.parameters.is_empty() {
let params: Vec<Value> = op
.shape
.parameters
.iter()
.map(|p| {
json!({
"name": p.name,
"in": p.location,
"required": p.required,
"schema": p.schema,
})
})
.collect();
m.insert("parameters".into(), json!(params));
}
if let Some(body) = &op.shape.request {
m.insert(
"requestBody".into(),
json!({
"required": body.required,
"content": { body.content_type.clone(): { "schema": body.schema } },
}),
);
}
let mut responses = serde_json::Map::new();
for (code, resp) in &op.shape.responses {
let mut r = serde_json::Map::new();
let description = resp
.description
.clone()
.unwrap_or_else(|| describe_status(code).to_string());
r.insert("description".into(), json!(description));
if !resp.headers.is_empty() {
let headers: serde_json::Map<String, Value> = resp
.headers
.iter()
.map(|h| (h.clone(), json!({ "schema": { "type": "string" } })))
.collect();
r.insert("headers".into(), Value::Object(headers));
}
if !resp.content_type.is_empty() {
r.insert(
"content".into(),
json!({ resp.content_type.clone(): { "schema": resp.schema } }),
);
}
responses.insert(code.clone(), Value::Object(r));
}
if responses.is_empty() {
responses.insert("200".into(), json!({ "description": "OK" }));
}
m.insert("responses".into(), Value::Object(responses));
Value::Object(m)
}
fn describe_status(code: &str) -> &'static str {
match code {
"200" => "OK",
"201" => "Created",
"202" => "Accepted",
"204" => "No Content",
"301" => "Moved Permanently",
"302" => "Found",
"304" => "Not Modified",
"400" => "Bad Request",
"401" => "Unauthorized",
"403" => "Forbidden",
"404" => "Not Found",
"409" => "Conflict",
"422" => "Unprocessable Content",
"429" => "Too Many Requests",
"500" => "Internal Server Error",
"503" => "Service Unavailable",
"default" => "Unexpected error",
_ => "Response",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schema::route_sig::{Parameter, RequestBody, Response, RouteShape};
use crate::serialize::to_yaml;
fn info() -> Info {
Info {
title: "Items API".into(),
version: "2.1".into(),
description: None,
servers: vec!["https://api.example.com".into()],
}
}
fn shape() -> RouteShape {
RouteShape::default()
}
fn op(operation_id: &str, method: &str, path: &str, shape: RouteShape) -> Operation {
Operation {
operation_id: operation_id.into(),
method: method.into(),
path: path.into(),
summary: None,
shape,
}
}
fn json_response(schema: Value) -> Response {
Response::json(schema)
}
#[test]
fn emits_a_three_one_document_with_info_and_servers() {
let d = document(&info(), &[]);
assert_eq!(d.value["openapi"], "3.1.0");
assert_eq!(d.value["info"]["title"], "Items API");
assert_eq!(d.value["info"]["version"], "2.1");
assert_eq!(d.value["servers"][0]["url"], "https://api.example.com");
assert!(d.conflicts.is_empty());
}
#[test]
fn the_verb_becomes_a_lowercased_path_item_key() {
let d = document(&info(), &[op("createItem", "POST", "/items", shape())]);
assert_eq!(
d.value["paths"]["/items"]["post"]["operationId"],
"createItem"
);
}
#[test]
fn two_verbs_on_one_path_merge_into_a_single_path_item() {
let d = document(
&info(),
&[
op("listItems", "GET", "/items", shape()),
op("createItem", "POST", "/items", shape()),
],
);
let item = &d.value["paths"]["/items"];
assert_eq!(item["get"]["operationId"], "listItems");
assert_eq!(item["post"]["operationId"], "createItem");
assert!(d.conflicts.is_empty());
}
#[test]
fn a_duplicated_verb_and_path_is_a_conflict_not_a_silent_overwrite() {
let d = document(
&info(),
&[
op("listItems", "GET", "/items", shape()),
op("otherList", "GET", "/items", shape()),
],
);
assert_eq!(d.conflicts.len(), 1, "got {:?}", d.conflicts);
assert_eq!(
d.value["paths"]["/items"]["get"]["operationId"],
"listItems"
);
}
#[test]
fn a_method_from_another_dialect_is_a_conflict() {
let d = document(
&info(),
&[op("streamPrices", "SUBSCRIBE", "prices", shape())],
);
assert_eq!(d.conflicts.len(), 1, "got {:?}", d.conflicts);
assert!(d.conflicts[0].contains("SUBSCRIBE"), "{:?}", d.conflicts);
assert!(
d.value["paths"].as_object().is_some_and(|p| p.is_empty()),
"the operation is not emitted under a guessed verb"
);
}
#[test]
fn components_shared_between_operations_merge_cleanly() {
let mut a = shape();
a.components
.insert("Item".into(), json!({ "type": "object" }));
let mut b = shape();
b.components
.insert("Item".into(), json!({ "type": "object" }));
let d = document(&info(), &[op("a", "GET", "/a", a), op("b", "GET", "/b", b)]);
assert!(d.conflicts.is_empty(), "identical definitions agree");
assert_eq!(d.value["components"]["schemas"]["Item"]["type"], "object");
}
#[test]
fn components_that_disagree_are_reported() {
let mut a = shape();
a.components
.insert("Item".into(), json!({ "type": "object" }));
let mut b = shape();
b.components
.insert("Item".into(), json!({ "type": "string" }));
let d = document(&info(), &[op("a", "GET", "/a", a), op("b", "GET", "/b", b)]);
assert_eq!(d.conflicts.len(), 1, "got {:?}", d.conflicts);
assert!(d.conflicts[0].contains("Item"));
}
#[test]
fn every_response_carries_the_description_openapi_requires() {
let mut s = shape();
s.responses
.insert("201".into(), json_response(json!({ "type": "object" })));
s.responses
.insert("default".into(), json_response(json!({})));
let d = document(&info(), &[op("createItem", "POST", "/items", s)]);
let responses = &d.value["paths"]["/items"]["post"]["responses"];
assert_eq!(responses["201"]["description"], "Created");
assert_eq!(responses["default"]["description"], "Unexpected error");
}
#[test]
fn a_no_content_response_has_no_content_block() {
let mut s = shape();
s.responses.insert("204".into(), Response::empty());
let d = document(&info(), &[op("deleteItem", "DELETE", "/items/{id}", s)]);
let r = &d.value["paths"]["/items/{id}"]["delete"]["responses"]["204"];
assert_eq!(r["description"], "No Content");
assert!(r.get("content").is_none(), "204 carries no body");
}
#[test]
fn an_operation_with_no_responses_still_emits_one() {
let d = document(&info(), &[op("ping", "GET", "/ping", shape())]);
assert_eq!(
d.value["paths"]["/ping"]["get"]["responses"]["200"]["description"],
"OK"
);
}
#[test]
fn request_body_uses_the_content_type_the_extractor_implied() {
let mut s = shape();
s.request = Some(RequestBody {
content_type: "application/x-www-form-urlencoded".into(),
schema: json!({ "$ref": "#/components/schemas/NewItem" }),
required: true,
});
let d = document(&info(), &[op("createItem", "POST", "/items", s)]);
let body = &d.value["paths"]["/items"]["post"]["requestBody"];
assert_eq!(body["required"], true);
assert_eq!(
body["content"]["application/x-www-form-urlencoded"]["schema"]["$ref"],
"#/components/schemas/NewItem"
);
}
#[test]
fn parameters_render_with_location_and_requiredness() {
let mut s = shape();
s.parameters.push(Parameter {
name: "id".into(),
location: "path".into(),
required: true,
schema: json!({ "type": "integer" }),
});
s.parameters.push(Parameter {
name: "verbose".into(),
location: "query".into(),
required: false,
schema: json!({ "type": "boolean" }),
});
let d = document(&info(), &[op("getItem", "GET", "/items/{id}", s)]);
let params = d.value["paths"]["/items/{id}"]["get"]["parameters"]
.as_array()
.expect("parameters");
assert_eq!(params[0]["name"], "id");
assert_eq!(params[0]["in"], "path");
assert_eq!(params[0]["required"], true);
assert_eq!(params[1]["required"], false);
}
#[test]
fn yaml_output_is_stable_and_leads_with_the_version() {
let mut s = shape();
s.responses
.insert("200".into(), json_response(json!({ "type": "object" })));
s.components
.insert("Item".into(), json!({ "type": "object" }));
let d = document(&info(), &[op("getItem", "GET", "/items/{id}", s)]);
let a = to_yaml(&d.value);
let b = to_yaml(&d.value);
assert_eq!(a, b, "regeneration must be byte-identical");
assert!(a.starts_with("openapi: 3.1.0"), "got:\n{a}");
assert!(a.ends_with('\n'), "file ends with a newline");
}
}