#![deny(missing_docs)]
use churust_core::{Call, IntoResponse, Response, RouteBuilder};
use http::header::CONTENT_TYPE;
use http::{HeaderValue, Method};
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Default)]
pub struct Operation {
summary: Option<String>,
description: Option<String>,
operation_id: Option<String>,
tags: Vec<String>,
deprecated: bool,
request_body: Option<(String, Option<Value>, bool)>,
responses: BTreeMap<u16, (String, Option<Value>)>,
query_params: Vec<(String, bool, Option<String>)>,
}
impl Operation {
pub fn new() -> Self {
Self::default()
}
pub fn summary(mut self, text: impl Into<String>) -> Self {
self.summary = Some(text.into());
self
}
pub fn description(mut self, text: impl Into<String>) -> Self {
self.description = Some(text.into());
self
}
pub fn operation_id(mut self, id: impl Into<String>) -> Self {
self.operation_id = Some(id.into());
self
}
pub fn tag(mut self, tag: impl Into<String>) -> Self {
self.tags.push(tag.into());
self
}
pub fn deprecated(mut self) -> Self {
self.deprecated = true;
self
}
pub fn query(mut self, name: impl Into<String>, required: bool) -> Self {
self.query_params.push((name.into(), required, None));
self
}
pub fn query_described(
mut self,
name: impl Into<String>,
required: bool,
description: impl Into<String>,
) -> Self {
self.query_params
.push((name.into(), required, Some(description.into())));
self
}
pub fn accepts(mut self, media_type: impl Into<String>) -> Self {
self.request_body = Some((media_type.into(), None, true));
self
}
pub fn schema(mut self, media_type: impl Into<String>, schema: Value) -> Self {
self.request_body = Some((media_type.into(), Some(schema), true));
self
}
pub fn response(mut self, status: u16, description: impl Into<String>) -> Self {
self.responses.insert(status, (description.into(), None));
self
}
pub fn response_schema(
mut self,
status: u16,
description: impl Into<String>,
schema: Value,
) -> Self {
self.responses
.insert(status, (description.into(), Some(schema)));
self
}
}
#[derive(Debug, Clone)]
pub struct OpenApi {
title: String,
version: String,
description: Option<String>,
servers: Vec<(String, Option<String>)>,
routes: Vec<(Method, String)>,
operations: BTreeMap<(String, String), Operation>,
}
impl OpenApi {
pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
Self {
title: title.into(),
version: version.into(),
description: None,
servers: Vec::new(),
routes: Vec::new(),
operations: BTreeMap::new(),
}
}
pub fn description(mut self, text: impl Into<String>) -> Self {
self.description = Some(text.into());
self
}
pub fn server(mut self, url: impl Into<String>, description: Option<&str>) -> Self {
self.servers
.push((url.into(), description.map(str::to_string)));
self
}
pub fn from_routes(mut self, routes: &[(Method, String)]) -> Self {
for (method, path) in routes {
if method == Method::HEAD || method == Method::OPTIONS {
continue;
}
if !self
.routes
.iter()
.any(|r| r == &(method.clone(), path.clone()))
{
self.routes.push((method.clone(), path.clone()));
}
}
self
}
pub fn operation(mut self, method: Method, path: &str, operation: Operation) -> Self {
self.operations
.insert((path.to_string(), method.as_str().to_string()), operation);
self
}
pub fn undescribed(&self) -> Vec<(Method, String)> {
self.routes
.iter()
.filter(|(method, path)| {
!self
.operations
.contains_key(&(path.clone(), method.as_str().to_string()))
})
.cloned()
.collect()
}
pub fn stale(&self) -> Vec<(Method, String)> {
self.operations
.keys()
.filter(|(path, method)| {
!self
.routes
.iter()
.any(|(m, p)| m.as_str() == method && p == path)
})
.filter_map(|(path, method)| {
Method::from_bytes(method.as_bytes())
.ok()
.map(|m| (m, path.clone()))
})
.collect()
}
pub fn to_value(&self) -> Value {
let mut paths = Map::new();
for (method, path) in &self.routes {
let documented_path = openapi_path(path);
let entry = paths
.entry(documented_path.clone())
.or_insert_with(|| json!({}));
let Some(item) = entry.as_object_mut() else {
continue;
};
let described = self
.operations
.get(&(path.clone(), method.as_str().to_string()));
item.insert(
method.as_str().to_ascii_lowercase(),
self.operation_value(method, path, described),
);
}
let mut info = json!({
"title": self.title,
"version": self.version,
});
if let (Some(description), Some(map)) = (&self.description, info.as_object_mut()) {
map.insert("description".into(), json!(description));
}
let mut doc = json!({
"openapi": "3.1.0",
"info": info,
"paths": paths,
});
if !self.servers.is_empty() {
let servers: Vec<Value> = self
.servers
.iter()
.map(|(url, description)| match description {
Some(d) => json!({ "url": url, "description": d }),
None => json!({ "url": url }),
})
.collect();
if let Some(map) = doc.as_object_mut() {
map.insert("servers".into(), json!(servers));
}
}
doc
}
fn operation_value(&self, method: &Method, path: &str, described: Option<&Operation>) -> Value {
let mut out = Map::new();
let default = Operation::default();
let op = described.unwrap_or(&default);
out.insert(
"operationId".into(),
json!(op
.operation_id
.clone()
.unwrap_or_else(|| derive_operation_id(method, path))),
);
if let Some(summary) = &op.summary {
out.insert("summary".into(), json!(summary));
}
if let Some(description) = &op.description {
out.insert("description".into(), json!(description));
}
if !op.tags.is_empty() {
out.insert("tags".into(), json!(op.tags));
}
if op.deprecated {
out.insert("deprecated".into(), json!(true));
}
let mut parameters: Vec<Value> = path_parameters(path);
for (name, required, description) in &op.query_params {
let mut param = json!({
"name": name,
"in": "query",
"required": required,
"schema": { "type": "string" },
});
if let (Some(d), Some(map)) = (description, param.as_object_mut()) {
map.insert("description".into(), json!(d));
}
parameters.push(param);
}
if !parameters.is_empty() {
out.insert("parameters".into(), json!(parameters));
}
if let Some((media_type, schema, required)) = &op.request_body {
let content = match schema {
Some(schema) => json!({ media_type.clone(): { "schema": schema } }),
None => json!({ media_type.clone(): {} }),
};
out.insert(
"requestBody".into(),
json!({ "required": required, "content": content }),
);
}
let mut responses = Map::new();
if op.responses.is_empty() {
responses.insert(
"default".into(),
json!({ "description": "Undocumented response" }),
);
}
for (status, (description, schema)) in &op.responses {
let value = match schema {
Some(schema) => json!({
"description": description,
"content": { "application/json": { "schema": schema } },
}),
None => json!({ "description": description }),
};
responses.insert(status.to_string(), value);
}
out.insert("responses".into(), json!(responses));
Value::Object(out)
}
pub fn mount(&self, routes: &mut RouteBuilder, path: &str) {
let rendered = self.to_value().to_string();
routes.get(path, move |_c: Call| {
let body = rendered.clone();
async move { JsonDocument(body) }
});
}
}
struct JsonDocument(String);
impl IntoResponse for JsonDocument {
fn into_response(self) -> Response {
let mut res = Response::text(self.0);
res.headers
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
res
}
}
fn openapi_path(pattern: &str) -> String {
pattern.replace("...}", "}")
}
fn path_parameters(pattern: &str) -> Vec<Value> {
let mut out = Vec::new();
for segment in pattern.split('/') {
let Some(inner) = segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) else {
continue;
};
let (name, spans_slashes) = match inner.strip_suffix("...") {
Some(name) => (name, true),
None => (inner, false),
};
let mut param = json!({
"name": name,
"in": "path",
"required": true,
"schema": { "type": "string" },
});
if spans_slashes {
if let Some(map) = param.as_object_mut() {
map.insert(
"description".into(),
json!("Matches the remaining path, including slashes."),
);
}
}
out.push(param);
}
out
}
fn derive_operation_id(method: &Method, path: &str) -> String {
let mut out = method.as_str().to_ascii_lowercase();
for segment in path.split('/').filter(|s| !s.is_empty()) {
match segment.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
Some(param) => {
let name = param.trim_end_matches("...");
out.push_str("By");
out.push_str(&capitalize(name));
}
None => out.push_str(&capitalize(segment)),
}
}
out
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn routes() -> Vec<(Method, String)> {
vec![
(Method::GET, "/users/{id}".to_string()),
(Method::POST, "/users".to_string()),
(Method::GET, "/files/{path...}".to_string()),
]
}
#[test]
fn every_route_becomes_an_operation() {
let doc = OpenApi::new("API", "1.0").from_routes(&routes()).to_value();
let paths = doc["paths"].as_object().unwrap();
assert!(paths.contains_key("/users/{id}"));
assert!(paths.contains_key("/users"));
assert!(paths["/users/{id}"].get("get").is_some());
assert!(paths["/users"].get("post").is_some());
}
#[test]
fn a_wildcard_becomes_an_ordinary_parameter() {
let doc = OpenApi::new("API", "1.0").from_routes(&routes()).to_value();
let paths = doc["paths"].as_object().unwrap();
assert!(
paths.contains_key("/files/{path}"),
"OpenAPI has no slash-spanning parameter: {:?}",
paths.keys().collect::<Vec<_>>()
);
let params = paths["/files/{path}"]["get"]["parameters"]
.as_array()
.unwrap();
assert_eq!(params[0]["name"], "path");
assert!(params[0]["description"]
.as_str()
.unwrap()
.contains("including slashes"));
}
#[test]
fn path_parameters_are_derived_and_required() {
let doc = OpenApi::new("API", "1.0").from_routes(&routes()).to_value();
let params = doc["paths"]["/users/{id}"]["get"]["parameters"]
.as_array()
.unwrap();
assert_eq!(params.len(), 1);
assert_eq!(params[0]["name"], "id");
assert_eq!(params[0]["in"], "path");
assert_eq!(params[0]["required"], true);
}
#[test]
fn an_undescribed_operation_still_carries_a_response() {
let doc = OpenApi::new("API", "1.0").from_routes(&routes()).to_value();
let responses = doc["paths"]["/users"]["post"]["responses"]
.as_object()
.unwrap();
assert!(
responses.contains_key("default"),
"an operation with no responses fails validation"
);
}
#[test]
fn a_description_reaches_the_document() {
let doc = OpenApi::new("API", "1.0")
.from_routes(&routes())
.operation(
Method::GET,
"/users/{id}",
Operation::new()
.summary("Fetch one user")
.tag("users")
.response(200, "The user")
.response(404, "No such user"),
)
.to_value();
let op = &doc["paths"]["/users/{id}"]["get"];
assert_eq!(op["summary"], "Fetch one user");
assert_eq!(op["tags"][0], "users");
assert_eq!(op["responses"]["200"]["description"], "The user");
assert_eq!(op["responses"]["404"]["description"], "No such user");
assert!(op["responses"].get("default").is_none());
}
#[test]
fn a_schema_is_carried_through_unchanged() {
let schema = json!({ "type": "object", "properties": { "name": { "type": "string" } } });
let doc = OpenApi::new("API", "1.0")
.from_routes(&routes())
.operation(
Method::POST,
"/users",
Operation::new().schema("application/json", schema.clone()),
)
.to_value();
assert_eq!(
doc["paths"]["/users"]["post"]["requestBody"]["content"]["application/json"]["schema"],
schema
);
}
#[test]
fn operation_ids_are_derived_and_stable() {
assert_eq!(
derive_operation_id(&Method::GET, "/users/{id}"),
"getUsersById"
);
assert_eq!(derive_operation_id(&Method::POST, "/users"), "postUsers");
assert_eq!(
derive_operation_id(&Method::GET, "/files/{path...}"),
"getFilesByPath"
);
}
#[test]
fn head_and_options_are_not_described() {
let mut routes = routes();
routes.push((Method::HEAD, "/users".to_string()));
routes.push((Method::OPTIONS, "/users".to_string()));
let doc = OpenApi::new("API", "1.0").from_routes(&routes).to_value();
assert!(doc["paths"]["/users"].get("head").is_none());
assert!(doc["paths"]["/users"].get("options").is_none());
}
#[test]
fn drift_is_reported_in_both_directions() {
let api = OpenApi::new("API", "1.0")
.from_routes(&routes())
.operation(Method::GET, "/users/{id}", Operation::new())
.operation(Method::DELETE, "/gone", Operation::new());
let undescribed = api.undescribed();
assert!(undescribed.contains(&(Method::POST, "/users".to_string())));
assert!(!undescribed.contains(&(Method::GET, "/users/{id}".to_string())));
assert_eq!(api.stale(), vec![(Method::DELETE, "/gone".to_string())]);
}
#[test]
fn servers_and_description_are_included_when_set() {
let doc = OpenApi::new("API", "1.0")
.description("Everything about users")
.server("https://api.example.com", Some("production"))
.from_routes(&routes())
.to_value();
assert_eq!(doc["info"]["description"], "Everything about users");
assert_eq!(doc["servers"][0]["url"], "https://api.example.com");
assert_eq!(doc["servers"][0]["description"], "production");
}
#[test]
fn the_document_declares_openapi_3_1() {
let doc = OpenApi::new("API", "1.0").to_value();
assert_eq!(doc["openapi"], "3.1.0");
assert_eq!(doc["info"]["title"], "API");
assert_eq!(doc["info"]["version"], "1.0");
}
}