use headers::ContentType;
use indexmap::IndexMap;
use tracing::error;
use utoipa::openapi::Content;
use utoipa::openapi::path::{Operation, Parameter};
use utoipa::openapi::request_body::RequestBody;
use utoipa::openapi::security::SecurityRequirement as UtoipaSecurityRequirement;
use super::collectors::normalize_content_type;
use super::result::CallResult;
use crate::client::call_parameters::{CallParameters, OperationMetadata};
use crate::client::security::SecurityRequirement;
use crate::client::{CallBody, CallPath};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub(in crate::client) struct CalledOperation {
pub(in crate::client) operation_id: String,
pub(super) method: http::Method,
pub(super) path: String,
pub(super) operation: Operation,
pub(super) result: Option<CallResult>,
#[cfg(feature = "redaction")]
pub(super) response_description: Option<String>,
}
impl CalledOperation {
pub(in crate::client) fn build(
method: http::Method,
path_name: &str,
path: &CallPath,
parameters: CallParameters,
request_body: Option<&CallBody>,
metadata: OperationMetadata,
security: Option<Vec<SecurityRequirement>>,
) -> Self {
let mut all_parameters: Vec<_> = path.to_parameters().collect();
all_parameters.extend(parameters.to_parameters());
let mut schemas = path.schemas().clone();
schemas.merge(parameters.collect_schemas());
let final_description = metadata
.description
.or_else(|| generate_description(&method, path_name));
let final_tags = metadata.tags.or_else(|| generate_tags(path_name));
let builder = Operation::builder()
.operation_id(Some(&metadata.operation_id))
.parameters(Some(all_parameters))
.description(final_description)
.tags(final_tags);
let builder = if let Some(ref sec) = security {
let utoipa_security: Vec<UtoipaSecurityRequirement> =
sec.iter().map(SecurityRequirement::to_utoipa).collect();
builder.securities(Some(utoipa_security))
} else {
builder
};
let builder = if let Some(body) = request_body {
let schema_ref = schemas.add_entry(body.entry.clone());
let content_type = normalize_content_type(&body.content_type);
let example = if !body.entry.examples.is_empty() {
body.entry.examples.first().cloned()
} else if body.content_type == ContentType::json() {
serde_json::from_slice(&body.data).ok()
} else {
None
};
let content = Content::builder()
.schema(Some(schema_ref))
.example(example)
.build();
let request_body = RequestBody::builder()
.content(content_type, content)
.build();
builder.request_body(Some(request_body))
} else {
builder
};
let operation = builder.build();
Self {
operation_id: metadata.operation_id,
method,
path: path_name.to_string(),
operation,
result: None,
#[cfg(feature = "redaction")]
response_description: metadata.response_description,
}
}
pub(in crate::client) fn add_response(&mut self, call_result: CallResult) {
self.result = Some(call_result);
}
pub(in crate::client) fn tags(&self) -> Option<&Vec<String>> {
self.operation.tags.as_ref()
}
}
pub(super) fn merge_operation(
id: &str,
current: Option<Operation>,
new: Operation,
) -> Option<Operation> {
let Some(current) = current else {
return Some(new);
};
let current_id = current.operation_id.as_deref().unwrap_or_default();
if current_id != id {
error!("conflicting operation id {id} with {current_id}");
return None;
}
let operation = Operation::builder()
.tags(merge_tags(current.tags, new.tags))
.description(current.description.or(new.description))
.operation_id(Some(id))
.parameters(merge_parameters(current.parameters, new.parameters))
.request_body(merge_request_body(current.request_body, new.request_body))
.deprecated(current.deprecated.or(new.deprecated))
.securities(merge_security(current.security, new.security))
.responses(merge_responses(current.responses, new.responses));
Some(operation.build())
}
fn merge_request_body(
current: Option<RequestBody>,
new: Option<RequestBody>,
) -> Option<RequestBody> {
match (current, new) {
(Some(current), Some(new)) => {
let mut merged_content = current.content;
merged_content.extend(new.content);
let mut merged_builder = RequestBody::builder();
for (content_type, content) in merged_content {
merged_builder = merged_builder.content(content_type, content);
}
let merged = merged_builder
.description(current.description.or(new.description))
.required(current.required.or(new.required))
.build();
Some(merged)
}
(Some(current), None) => Some(current),
(None, Some(new)) => Some(new),
(None, None) => None,
}
}
fn merge_tags(current: Option<Vec<String>>, new: Option<Vec<String>>) -> Option<Vec<String>> {
let Some(mut current) = current else {
return new;
};
let Some(new) = new else {
return Some(current);
};
current.extend(new);
current.sort();
current.dedup();
Some(current)
}
fn merge_security(
current: Option<Vec<UtoipaSecurityRequirement>>,
new: Option<Vec<UtoipaSecurityRequirement>>,
) -> Option<Vec<UtoipaSecurityRequirement>> {
match (current, new) {
(_, Some(new)) => Some(new),
(current, None) => current,
}
}
fn merge_parameters(
current: Option<Vec<Parameter>>,
new: Option<Vec<Parameter>>,
) -> Option<Vec<Parameter>> {
let mut result = IndexMap::new();
for param in new.unwrap_or_default() {
result.insert(param.name.clone(), param);
}
for param in current.unwrap_or_default() {
result.entry(param.name.clone()).or_insert(param);
}
let result = result.into_values().collect();
Some(result)
}
fn merge_responses(
current: utoipa::openapi::Responses,
new: utoipa::openapi::Responses,
) -> utoipa::openapi::Responses {
use utoipa::openapi::ResponsesBuilder;
let mut merged_responses = IndexMap::new();
for (status, response) in new.responses {
merged_responses.insert(status, response);
}
for (status, response) in current.responses {
merged_responses.entry(status).or_insert(response);
}
let mut builder = ResponsesBuilder::new();
for (status, response) in merged_responses {
builder = builder.response(status, response);
}
builder.build()
}
const SKIP_PATH_PREFIXES: &[&str] = &[
"api", "v1", "v2", "v3", "rest", "service", "public", "internal", ];
pub(super) fn generate_description(method: &http::Method, path: &str) -> Option<String> {
let path = path.trim_start_matches('/');
let segments: Vec<&str> = path.split('/').collect();
if segments.is_empty() || (segments.len() == 1 && segments[0].is_empty()) {
return None;
}
let start_index = segments
.iter()
.take_while(|&segment| SKIP_PATH_PREFIXES.contains(segment))
.count();
if start_index >= segments.len() {
return None;
}
let resource = if segments.len() == start_index + 1 {
segments[start_index]
} else if segments.len() >= start_index + 2 {
let last_segment = segments.last().expect("segments length already checked");
if last_segment.starts_with('{') && last_segment.ends_with('}') {
segments[segments.len() - 2]
} else if segments.len() > start_index + 1 {
let resource_name = segments[start_index];
let action = last_segment;
match *action {
"import" => return Some(format!("Import {resource_name}")),
"upload" => return Some(format!("Upload {resource_name}")),
"export" => return Some(format!("Export {resource_name}")),
"search" => return Some(format!("Search {resource_name}")),
_ => last_segment, }
} else {
last_segment
}
} else {
segments[start_index]
};
let has_id = segments
.iter()
.any(|segment| segment.starts_with('{') && segment.ends_with('}'));
let action = match *method {
http::Method::GET => {
if has_id {
format!("Retrieve {} by ID", singularize(resource))
} else {
format!("Retrieve {resource}")
}
}
http::Method::POST => {
if has_id {
format!("Create {} by ID", singularize(resource))
} else {
format!("Create {}", singularize(resource))
}
}
http::Method::PUT => {
if has_id {
format!("Update {} by ID", singularize(resource))
} else {
format!("Update {resource}")
}
}
http::Method::PATCH => {
if has_id {
format!("Partially update {} by ID", singularize(resource))
} else {
format!("Partially update {resource}")
}
}
http::Method::DELETE => {
if has_id {
format!("Delete {} by ID", singularize(resource))
} else {
format!("Delete {resource}")
}
}
_ => return None,
};
Some(action)
}
pub(super) fn generate_tags(path: &str) -> Option<Vec<String>> {
let path = path.trim_start_matches('/');
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if segments.is_empty() {
return None;
}
let mut tags = Vec::new();
let start_index = segments
.iter()
.take_while(|&segment| SKIP_PATH_PREFIXES.contains(segment))
.count();
if start_index >= segments.len() {
return None;
}
let resource = segments[start_index];
tags.push(resource.to_string());
if segments.len() > start_index + 1 {
let last_segment = segments.last().expect("segments length already checked");
if !last_segment.starts_with('{') {
match *last_segment {
"import" | "upload" | "export" | "search" | "bulk" => {
tags.push(last_segment.to_string());
}
_ => {
if segments.len() == start_index + 2 {
tags.push(last_segment.to_string());
}
}
}
}
}
if tags.is_empty() { None } else { Some(tags) }
}
pub(super) fn singularize(word: &str) -> String {
match word {
"children" => return "child".to_string(),
"people" => return "person".to_string(),
"data" => return "datum".to_string(),
"feet" => return "foot".to_string(),
"teeth" => return "tooth".to_string(),
"geese" => return "goose".to_string(),
"men" => return "man".to_string(),
"women" => return "woman".to_string(),
_ => {}
}
use cruet::*;
let result = word.to_singular();
if result.is_empty() && !word.is_empty() {
word.to_string()
} else {
result
}
}