Files
crank/crates/crank-import/src/rest/payload.rs
T
2026-06-24 10:45:09 +00:00

244 lines
7.8 KiB
Rust

use std::collections::{BTreeMap, BTreeSet};
use crank_core::{
ExecutionConfig, HttpMethod, OperationSafetyClass, OperationSafetyPolicy, Protocol, RestTarget,
ToolDescription, ToolExample, WizardState,
};
use serde_json::json;
use crate::rest::{
mapping::{BodyFieldMapping, input_mapping, output_mapping},
model::{
ImportOperationCandidate, RestImportCandidate, RestImportOperation, RestParameterLocation,
},
naming::{fallback_operation_seed, title_from_name, unique_name},
recommendations::{candidate_recommendations, operation_recommendations},
schema::{any_object, field_count, object_with_fields, schema_from_openapi},
};
pub fn candidate_from_operation(
operation: &RestImportOperation,
document_servers: &[String],
used_names: &mut BTreeSet<String>,
) -> ImportOperationCandidate {
let method_name = method_as_str(operation.method);
let seed = operation
.operation_id
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| fallback_operation_seed(method_name, &operation.path));
let name = unique_name(&seed, used_names);
let display_name = operation
.summary
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| title_from_name(&name));
let description = operation
.description
.as_deref()
.or(operation.summary.as_deref())
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("Выполняет {method_name} {}", operation.path));
let category = operation
.tags
.first()
.cloned()
.unwrap_or_else(|| "imported".to_owned());
let server_urls = if operation.servers.is_empty() {
document_servers.to_vec()
} else {
operation.servers.clone()
};
let base_url = server_urls.first().cloned().unwrap_or_default();
let mut input_fields = BTreeMap::new();
let mut used_input_field_names = BTreeSet::new();
for parameter in &operation.parameters {
used_input_field_names.insert(parameter.name.clone());
input_fields.insert(
parameter.name.clone(),
schema_from_openapi(
parameter.schema.as_ref(),
parameter.required || parameter.location == RestParameterLocation::Path,
parameter.description.clone(),
),
);
}
let body_fields = body_input_fields(
operation.request_body_schema.as_ref(),
&mut used_input_field_names,
&mut input_fields,
);
let input_schema = object_with_fields(
Some("Входные параметры MCP-инструмента".to_owned()),
input_fields,
);
let output_schema = operation
.response_schema
.as_ref()
.map(|schema| schema_from_openapi(Some(schema), true, Some("Ответ API".to_owned())))
.unwrap_or_else(|| any_object(Some("Ответ API".to_owned())));
let input_mapping = input_mapping(&operation.parameters, &body_fields);
let output_mapping = output_mapping(&output_schema);
let mut findings = operation_recommendations(operation);
findings.extend(candidate_recommendations(
operation,
&name,
&description,
&input_schema,
&output_schema,
));
let draft = RestImportCandidate {
name: name.clone(),
display_name: display_name.clone(),
category: category.clone(),
target: RestTarget {
base_url,
method: operation.method,
path_template: operation.path.clone(),
static_headers: BTreeMap::new(),
},
input_schema: input_schema.clone(),
output_schema: output_schema.clone(),
input_mapping,
output_mapping,
tool_description: ToolDescription {
title: display_name.clone(),
description: description.clone(),
tags: operation.tags.clone(),
examples: vec![ToolExample { input: json!({}) }],
},
wizard_state: Some(WizardState {
input_sample: None,
output_sample: None,
test_input: None,
import_findings: Vec::new(),
}),
};
ImportOperationCandidate {
key: operation.key.clone(),
method: operation.method,
path: operation.path.clone(),
operation_id: operation.operation_id.clone(),
suggested_name: name,
suggested_display_name: display_name,
description,
category,
input_fields: field_count(&input_schema),
output_fields: field_count(&output_schema),
server_urls,
findings,
draft,
}
}
fn body_input_fields(
request_body_schema: Option<&serde_json::Value>,
used_names: &mut BTreeSet<String>,
input_fields: &mut BTreeMap<String, crank_schema::Schema>,
) -> Vec<BodyFieldMapping> {
let Some(request_body_schema) = request_body_schema else {
return Vec::new();
};
let body_schema = schema_from_openapi(
Some(request_body_schema),
true,
Some("Тело запроса".to_owned()),
);
if body_schema.kind != crank_schema::SchemaKind::Object || body_schema.fields.is_empty() {
let input_name = unique_body_input_name("body", used_names);
input_fields.insert(input_name.clone(), body_schema);
return vec![BodyFieldMapping {
input_name,
body_path: String::new(),
required: true,
}];
}
let mut mappings = Vec::new();
for (field_name, field_schema) in body_schema.fields {
let input_name = unique_body_input_name(&field_name, used_names);
mappings.push(BodyFieldMapping {
input_name: input_name.clone(),
body_path: field_name,
required: field_schema.required,
});
input_fields.insert(input_name, field_schema);
}
mappings
}
fn unique_body_input_name(name: &str, used_names: &mut BTreeSet<String>) -> String {
if used_names.insert(name.to_owned()) {
return name.to_owned();
}
let prefixed = format!("body_{name}");
if used_names.insert(prefixed.clone()) {
return prefixed;
}
for index in 2.. {
let candidate = format!("body_{name}_{index}");
if used_names.insert(candidate.clone()) {
return candidate;
}
}
unreachable!()
}
pub fn operation_draft_from_candidate(
candidate: &ImportOperationCandidate,
server_url: Option<&str>,
) -> RestImportCandidate {
let mut draft = candidate.draft.clone();
if let Some(server_url) = server_url.filter(|value| !value.trim().is_empty()) {
draft.target.base_url = server_url.trim().trim_end_matches('/').to_owned();
}
draft
}
fn method_as_str(method: HttpMethod) -> &'static str {
match method {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
HttpMethod::Put => "PUT",
HttpMethod::Patch => "PATCH",
HttpMethod::Delete => "DELETE",
}
}
#[allow(dead_code)]
fn safety_for_method(method: HttpMethod) -> OperationSafetyPolicy {
let class = match method {
HttpMethod::Get => OperationSafetyClass::Read,
HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch => OperationSafetyClass::Write,
HttpMethod::Delete => OperationSafetyClass::Destructive,
};
OperationSafetyPolicy {
class,
confirmation: None,
}
}
#[allow(dead_code)]
fn default_execution_config() -> ExecutionConfig {
ExecutionConfig {
timeout_ms: 10_000,
retry_policy: None,
response_cache: None,
idempotency: None,
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
}
}
#[allow(dead_code)]
fn _protocol() -> Protocol {
Protocol::Rest
}