Add OpenAPI import backend
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
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,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _protocol() -> Protocol {
|
||||
Protocol::Rest
|
||||
}
|
||||
Reference in New Issue
Block a user