244 lines
7.8 KiB
Rust
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
|
|
}
|