use headers::ContentType;
use http::{Method, StatusCode};
use indexmap::IndexMap;
use tracing::warn;
use utoipa::openapi::{Content, PathItem, RefOr, Response, ResponseBuilder, Schema};
use super::operation::{CalledOperation, merge_operation};
use super::schema::Schemas;
pub(in crate::client) fn build_response(
description: String,
content_type: Option<&ContentType>,
schema: Option<RefOr<Schema>>,
example: Option<serde_json::Value>,
) -> Response {
if let Some(content_type) = content_type {
let content = Content::builder().schema(schema).example(example).build();
ResponseBuilder::new()
.description(description)
.content(content_type.to_string(), content)
.build()
} else {
ResponseBuilder::new().description(description).build()
}
}
pub(super) fn normalize_content_type(content_type: &ContentType) -> String {
let content_type_str = content_type.to_string();
if let Some(semicolon_pos) = content_type_str.find(';') {
content_type_str[..semicolon_pos].to_string()
} else {
content_type_str
}
}
#[derive(Debug, Clone, Default)]
pub(in crate::client) struct Collectors {
pub(super) operations: IndexMap<String, Vec<CalledOperation>>,
pub(in crate::client) schemas: Schemas,
}
impl Collectors {
pub(in crate::client) fn collect_schemas(&mut self, schemas: Schemas) {
self.schemas.merge(schemas);
}
pub(in crate::client) fn collect_schema_entry(&mut self, entry: super::schema::SchemaEntry) {
self.schemas.add_entry(entry);
}
pub(in crate::client) fn collect_operation(
&mut self,
operation: CalledOperation,
) -> Option<&mut CalledOperation> {
let operation_id = operation.operation_id.clone();
let operations = self.operations.entry(operation_id).or_default();
operations.push(operation);
operations.last_mut()
}
pub(in crate::client) fn schemas(&self) -> Vec<(String, RefOr<Schema>)> {
self.schemas.schema_vec()
}
pub(in crate::client) fn operations(&self) -> impl Iterator<Item = &CalledOperation> {
self.operations.values().flatten()
}
pub(in crate::client) fn register_response(
&mut self,
operation_id: &str,
status: StatusCode,
content_type: Option<&ContentType>,
schema: Option<RefOr<Schema>>,
description: String,
) {
let Some(operations) = self.operations.get_mut(operation_id) else {
tracing::warn!(%operation_id, "Operation not found for response registration");
return;
};
let Some(operation) = operations.last_mut() else {
return;
};
let response = build_response(description, content_type, schema, None);
operation
.operation
.responses
.responses
.insert(status.as_u16().to_string(), RefOr::T(response));
}
#[cfg(feature = "redaction")]
pub(in crate::client) fn register_response_with_example(
&mut self,
operation_id: &str,
status: StatusCode,
content_type: Option<&ContentType>,
schema: RefOr<Schema>,
example: serde_json::Value,
) {
let Some(operations) = self.operations.get_mut(operation_id) else {
return;
};
let Some(operation) = operations.last_mut() else {
return;
};
let description = operation
.response_description
.clone()
.unwrap_or_else(|| format!("Status code {}", status.as_u16()));
let response = build_response(description, content_type, Some(schema), Some(example));
operation
.operation
.responses
.responses
.insert(status.as_u16().to_string(), RefOr::T(response));
}
pub(in crate::client) fn as_map(&mut self, base_path: &str) -> IndexMap<String, PathItem> {
macro_rules! merge_into {
($item:expr, $field:ident, $operation_id:expr, $operation:expr) => {{ $item.$field = merge_operation($operation_id, $item.$field.clone(), $operation) }};
}
let mut result = IndexMap::<String, PathItem>::new();
for (operation_id, calls) in &self.operations {
debug_assert!(!calls.is_empty(), "having at least a call");
let path = format!("{base_path}/{}", calls[0].path.trim_start_matches('/'));
let item = result.entry(path.clone()).or_default();
for call in calls {
match &call.method {
&Method::GET => merge_into!(item, get, operation_id, call.operation.clone()),
&Method::PUT => merge_into!(item, put, operation_id, call.operation.clone()),
&Method::POST => merge_into!(item, post, operation_id, call.operation.clone()),
&Method::DELETE => {
merge_into!(item, delete, operation_id, call.operation.clone())
}
&Method::OPTIONS => {
merge_into!(item, options, operation_id, call.operation.clone())
}
&Method::HEAD => merge_into!(item, head, operation_id, call.operation.clone()),
&Method::PATCH => {
merge_into!(item, patch, operation_id, call.operation.clone())
}
&Method::TRACE => {
merge_into!(item, trace, operation_id, call.operation.clone())
}
method => warn!(%method, "unsupported method"),
}
}
}
result
}
}
#[cfg(test)]
mod operation_metadata_tests {
use super::super::operation::{generate_description, generate_tags, singularize};
use super::*;
use http::Method;
#[test]
fn test_generate_description_simple_paths() {
assert_eq!(
generate_description(&Method::GET, "/users"),
Some("Retrieve users".to_string())
);
assert_eq!(
generate_description(&Method::POST, "/users"),
Some("Create user".to_string())
);
assert_eq!(
generate_description(&Method::PUT, "/users"),
Some("Update users".to_string())
);
assert_eq!(
generate_description(&Method::DELETE, "/users"),
Some("Delete users".to_string())
);
assert_eq!(
generate_description(&Method::PATCH, "/users"),
Some("Partially update users".to_string())
);
}
#[test]
fn test_generate_description_with_id_parameter() {
assert_eq!(
generate_description(&Method::GET, "/users/{id}"),
Some("Retrieve user by ID".to_string())
);
assert_eq!(
generate_description(&Method::PUT, "/users/{id}"),
Some("Update user by ID".to_string())
);
assert_eq!(
generate_description(&Method::DELETE, "/users/{id}"),
Some("Delete user by ID".to_string())
);
assert_eq!(
generate_description(&Method::PATCH, "/users/{id}"),
Some("Partially update user by ID".to_string())
);
}
#[test]
fn test_generate_description_special_actions() {
assert_eq!(
generate_description(&Method::POST, "/observations/import"),
Some("Import observations".to_string())
);
assert_eq!(
generate_description(&Method::POST, "/observations/upload"),
Some("Upload observations".to_string())
);
assert_eq!(
generate_description(&Method::POST, "/users/export"),
Some("Export users".to_string())
);
assert_eq!(
generate_description(&Method::GET, "/users/search"),
Some("Search users".to_string())
);
}
#[test]
fn test_generate_description_api_prefix() {
assert_eq!(
generate_description(&Method::GET, "/api/observations"),
Some("Retrieve observations".to_string())
);
assert_eq!(
generate_description(&Method::POST, "/api/observations/import"),
Some("Import observations".to_string())
);
assert_eq!(
generate_description(&Method::GET, "/api/v1/users"),
Some("Retrieve users".to_string())
);
assert_eq!(
generate_description(&Method::POST, "/rest/service/items"),
Some("Create item".to_string())
);
}
#[test]
fn test_generate_tags_simple_paths() {
assert_eq!(generate_tags("/users"), Some(vec!["users".to_string()]));
assert_eq!(
generate_tags("/observations"),
Some(vec!["observations".to_string()])
);
}
#[test]
fn test_generate_tags_with_api_prefix() {
assert_eq!(generate_tags("/api/users"), Some(vec!["users".to_string()]));
assert_eq!(
generate_tags("/api/observations"),
Some(vec!["observations".to_string()])
);
assert_eq!(
generate_tags("/api/v1/users"),
Some(vec!["users".to_string()])
);
assert_eq!(
generate_tags("/rest/service/items"),
Some(vec!["items".to_string()])
);
}
#[test]
fn test_generate_tags_with_special_actions() {
assert_eq!(
generate_tags("/api/observations/import"),
Some(vec!["observations".to_string(), "import".to_string()])
);
assert_eq!(
generate_tags("/api/observations/upload"),
Some(vec!["observations".to_string(), "upload".to_string()])
);
assert_eq!(
generate_tags("/users/export"),
Some(vec!["users".to_string(), "export".to_string()])
);
}
#[test]
fn test_generate_tags_with_id_parameter() {
assert_eq!(
generate_tags("/api/observations/{id}"),
Some(vec!["observations".to_string()])
);
assert_eq!(
generate_tags("/users/{user_id}"),
Some(vec!["users".to_string()])
);
}
#[test]
fn test_singularize() {
assert_eq!(singularize("users"), "user");
assert_eq!(singularize("observations"), "observation");
assert_eq!(singularize("items"), "item");
assert_eq!(singularize("mice"), "mouse"); assert_eq!(singularize("children"), "child"); assert_eq!(singularize("people"), "person"); assert_eq!(singularize("feet"), "foot"); assert_eq!(singularize("teeth"), "tooth"); assert_eq!(singularize("geese"), "goose"); assert_eq!(singularize("men"), "man"); assert_eq!(singularize("women"), "woman"); assert_eq!(singularize("data"), "datum");
assert_eq!(singularize("boxes"), "box");
assert_eq!(singularize("watches"), "watch");
assert_eq!(singularize("user"), "user");
assert_eq!(singularize("child"), "child");
assert_eq!(singularize("s"), "s"); assert_eq!(singularize(""), "");
assert_eq!(singularize("categories"), "category");
assert_eq!(singularize("companies"), "company");
assert_eq!(singularize("libraries"), "library");
assert_eq!(singularize("stories"), "story");
assert_eq!(singularize("cities"), "city");
}
#[test]
fn test_normalize_json_content_type() {
let content_type = ContentType::json();
let normalized = normalize_content_type(&content_type);
assert_eq!(normalized, "application/json");
}
#[test]
fn test_normalize_multipart_content_type() {
let content_type_str = "multipart/form-data; boundary=----formdata-clawspec-12345";
let content_type = ContentType::from(
content_type_str
.parse::<mime::Mime>()
.expect("MIME type is valid"),
);
let normalized = normalize_content_type(&content_type);
assert_eq!(normalized, "multipart/form-data");
}
#[test]
fn test_normalize_form_urlencoded_content_type() {
let content_type = ContentType::form_url_encoded();
let normalized = normalize_content_type(&content_type);
assert_eq!(normalized, "application/x-www-form-urlencoded");
}
#[test]
fn test_normalize_content_type_with_charset() {
let content_type_str = "application/json; charset=utf-8";
let content_type = ContentType::from(
content_type_str
.parse::<mime::Mime>()
.expect("MIME type is valid"),
);
let normalized = normalize_content_type(&content_type);
assert_eq!(normalized, "application/json");
}
#[test]
fn test_normalize_content_type_with_multiple_parameters() {
let content_type_str = "text/html; charset=utf-8; boundary=something";
let content_type = ContentType::from(
content_type_str
.parse::<mime::Mime>()
.expect("MIME type is valid"),
);
let normalized = normalize_content_type(&content_type);
assert_eq!(normalized, "text/html");
}
#[test]
fn test_normalize_content_type_without_parameters() {
let content_type_str = "application/xml";
let content_type = ContentType::from(
content_type_str
.parse::<mime::Mime>()
.expect("MIME type is valid"),
);
let normalized = normalize_content_type(&content_type);
assert_eq!(normalized, "application/xml");
}
}