Add OpenAPI import backend
This commit is contained in:
@@ -0,0 +1 @@
|
||||
pub mod rest;
|
||||
@@ -0,0 +1,79 @@
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
|
||||
use crate::rest::model::{RestImportParameter, RestParameterLocation};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BodyFieldMapping {
|
||||
pub input_name: String,
|
||||
pub body_path: String,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
pub fn input_mapping(
|
||||
parameters: &[RestImportParameter],
|
||||
body_fields: &[BodyFieldMapping],
|
||||
) -> MappingSet {
|
||||
let mut rules = Vec::new();
|
||||
for parameter in parameters {
|
||||
let target_root = match parameter.location {
|
||||
RestParameterLocation::Path => "$.request.path",
|
||||
RestParameterLocation::Query => "$.request.query",
|
||||
RestParameterLocation::Header => "$.request.headers",
|
||||
};
|
||||
rules.push(MappingRule {
|
||||
source: format!("$.mcp.{}", parameter.name),
|
||||
target: format!("{target_root}.{}", parameter.name),
|
||||
required: parameter.required,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
});
|
||||
}
|
||||
for field in body_fields {
|
||||
let target = if field.body_path.is_empty() {
|
||||
"$.request.body".to_owned()
|
||||
} else {
|
||||
format!("$.request.body.{}", field.body_path)
|
||||
};
|
||||
rules.push(MappingRule {
|
||||
source: format!("$.mcp.{}", field.input_name),
|
||||
target,
|
||||
required: field.required,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
});
|
||||
}
|
||||
MappingSet { rules }
|
||||
}
|
||||
|
||||
pub fn output_mapping(output_schema: &Schema) -> MappingSet {
|
||||
let mut rules = Vec::new();
|
||||
if output_schema.kind == SchemaKind::Object && !output_schema.fields.is_empty() {
|
||||
for (name, field) in &output_schema.fields {
|
||||
rules.push(MappingRule {
|
||||
source: format!("$.response.body.{name}"),
|
||||
target: format!("$.output.{name}"),
|
||||
required: field.required,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
rules.push(MappingRule {
|
||||
source: "$.response.body".to_owned(),
|
||||
target: "$.output".to_owned(),
|
||||
required: true,
|
||||
default_value: None,
|
||||
transform: None,
|
||||
condition: None,
|
||||
notes: None,
|
||||
});
|
||||
}
|
||||
MappingSet { rules }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
mod mapping;
|
||||
pub mod model;
|
||||
mod naming;
|
||||
mod normalize;
|
||||
mod openapi3;
|
||||
mod payload;
|
||||
mod recommendations;
|
||||
mod schema;
|
||||
mod swagger2;
|
||||
|
||||
pub use model::{
|
||||
ImportFinding, ImportFindingSeverity, ImportGroupPreview, ImportOperationCandidate,
|
||||
ImportPreview, ImportSourcePreview, RestImportCandidate, RestImportDocument,
|
||||
RestImportOperation, RestImportParameter, RestParameterLocation,
|
||||
};
|
||||
pub use normalize::preview_document;
|
||||
pub use payload::operation_draft_from_candidate;
|
||||
@@ -0,0 +1,123 @@
|
||||
use crank_core::{HttpMethod, RestTarget, ToolDescription, WizardState};
|
||||
use crank_mapping::MappingSet;
|
||||
use crank_schema::Schema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ImportFindingSeverity {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImportFinding {
|
||||
pub code: String,
|
||||
pub severity: ImportFindingSeverity,
|
||||
pub message: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub operation_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ImportSourcePreview {
|
||||
pub format: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub servers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ImportPreview {
|
||||
pub source: ImportSourcePreview,
|
||||
pub groups: Vec<ImportGroupPreview>,
|
||||
#[serde(default)]
|
||||
pub findings: Vec<ImportFinding>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ImportGroupPreview {
|
||||
pub key: String,
|
||||
pub title: String,
|
||||
pub operations: Vec<ImportOperationCandidate>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ImportOperationCandidate {
|
||||
pub key: String,
|
||||
pub method: HttpMethod,
|
||||
pub path: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub operation_id: Option<String>,
|
||||
pub suggested_name: String,
|
||||
pub suggested_display_name: String,
|
||||
pub description: String,
|
||||
pub category: String,
|
||||
pub input_fields: usize,
|
||||
pub output_fields: usize,
|
||||
#[serde(default)]
|
||||
pub server_urls: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub findings: Vec<ImportFinding>,
|
||||
pub draft: RestImportCandidate,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RestImportCandidate {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub category: String,
|
||||
pub target: RestTarget,
|
||||
pub input_schema: Schema,
|
||||
pub output_schema: Schema,
|
||||
pub input_mapping: MappingSet,
|
||||
pub output_mapping: MappingSet,
|
||||
pub tool_description: ToolDescription,
|
||||
pub wizard_state: Option<WizardState>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RestImportDocument {
|
||||
pub format: String,
|
||||
pub version: Option<String>,
|
||||
pub title: String,
|
||||
pub servers: Vec<String>,
|
||||
pub operations: Vec<RestImportOperation>,
|
||||
pub findings: Vec<ImportFinding>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RestImportOperation {
|
||||
pub key: String,
|
||||
pub method: HttpMethod,
|
||||
pub path: String,
|
||||
pub operation_id: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub parameters: Vec<RestImportParameter>,
|
||||
pub request_body_schema: Option<Value>,
|
||||
pub response_schema: Option<Value>,
|
||||
pub servers: Vec<String>,
|
||||
pub findings: Vec<ImportFinding>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RestImportParameter {
|
||||
pub name: String,
|
||||
pub location: RestParameterLocation,
|
||||
pub required: bool,
|
||||
pub description: Option<String>,
|
||||
pub schema: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RestParameterLocation {
|
||||
Path,
|
||||
Query,
|
||||
Header,
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub fn snake_name(seed: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut prev_underscore = false;
|
||||
|
||||
for ch in seed.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
if ch.is_ascii_uppercase() && !out.is_empty() && !prev_underscore {
|
||||
out.push('_');
|
||||
}
|
||||
out.push(ch.to_ascii_lowercase());
|
||||
prev_underscore = false;
|
||||
} else if !out.is_empty() && !prev_underscore {
|
||||
out.push('_');
|
||||
prev_underscore = true;
|
||||
}
|
||||
}
|
||||
|
||||
let trimmed = out.trim_matches('_').to_owned();
|
||||
if trimmed.is_empty() {
|
||||
"imported_operation".to_owned()
|
||||
} else if trimmed.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
|
||||
format!("op_{trimmed}")
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unique_name(base: &str, used: &mut BTreeSet<String>) -> String {
|
||||
let clean = snake_name(base);
|
||||
if used.insert(clean.clone()) {
|
||||
return clean;
|
||||
}
|
||||
|
||||
for index in 2.. {
|
||||
let candidate = format!("{clean}_{index}");
|
||||
if used.insert(candidate.clone()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
pub fn title_from_name(name: &str) -> String {
|
||||
name.split('_')
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(|part| {
|
||||
let mut chars = part.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub fn fallback_operation_seed(method: &str, path: &str) -> String {
|
||||
format!("{method}_{path}")
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::rest::{
|
||||
model::{ImportGroupPreview, ImportPreview, RestImportDocument},
|
||||
openapi3,
|
||||
payload::candidate_from_operation,
|
||||
swagger2,
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ImportParseError {
|
||||
#[error("document is not valid YAML or JSON: {0}")]
|
||||
InvalidDocument(String),
|
||||
#[error("unsupported OpenAPI document")]
|
||||
UnsupportedDocument,
|
||||
}
|
||||
|
||||
pub fn preview_document(document: &str) -> Result<ImportPreview, ImportParseError> {
|
||||
let yaml: serde_yaml::Value = serde_yaml::from_str(document)
|
||||
.map_err(|error| ImportParseError::InvalidDocument(error.to_string()))?;
|
||||
let root = serde_json::to_value(yaml)
|
||||
.map_err(|error| ImportParseError::InvalidDocument(error.to_string()))?;
|
||||
|
||||
let normalized = if root.get("openapi").is_some() {
|
||||
openapi3::parse_document(&root)?
|
||||
} else if root.get("swagger").and_then(Value::as_str) == Some("2.0") {
|
||||
swagger2::parse_document(&root)?
|
||||
} else {
|
||||
return Err(ImportParseError::UnsupportedDocument);
|
||||
};
|
||||
|
||||
Ok(preview_from_document(normalized))
|
||||
}
|
||||
|
||||
fn preview_from_document(document: RestImportDocument) -> ImportPreview {
|
||||
let mut groups: BTreeMap<String, ImportGroupPreview> = BTreeMap::new();
|
||||
let mut used_names = BTreeSet::new();
|
||||
|
||||
for operation in &document.operations {
|
||||
let candidate = candidate_from_operation(operation, &document.servers, &mut used_names);
|
||||
let group_title = operation
|
||||
.tags
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Без группы".to_owned());
|
||||
let group_key = crate::rest::naming::snake_name(&group_title);
|
||||
groups
|
||||
.entry(group_key.clone())
|
||||
.or_insert_with(|| ImportGroupPreview {
|
||||
key: group_key,
|
||||
title: group_title,
|
||||
operations: Vec::new(),
|
||||
})
|
||||
.operations
|
||||
.push(candidate);
|
||||
}
|
||||
|
||||
ImportPreview {
|
||||
source: crate::rest::model::ImportSourcePreview {
|
||||
format: document.format,
|
||||
version: document.version,
|
||||
title: document.title,
|
||||
servers: document.servers,
|
||||
},
|
||||
groups: groups.into_values().collect(),
|
||||
findings: document.findings,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_local_ref(root: &Value, value: &Value, depth: usize) -> Value {
|
||||
if depth > 12 {
|
||||
return value.clone();
|
||||
}
|
||||
if let Some(reference) = value.get("$ref").and_then(Value::as_str) {
|
||||
if let Some(resolved) = pointer(root, reference) {
|
||||
return resolve_local_ref(root, resolved, depth + 1);
|
||||
}
|
||||
return value.clone();
|
||||
}
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
let mut out = serde_json::Map::new();
|
||||
for (key, item) in map {
|
||||
out.insert(key.clone(), resolve_local_ref(root, item, depth + 1));
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
Value::Array(items) => Value::Array(
|
||||
items
|
||||
.iter()
|
||||
.map(|item| resolve_local_ref(root, item, depth + 1))
|
||||
.collect(),
|
||||
),
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn pointer<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
|
||||
if !reference.starts_with("#/") {
|
||||
return None;
|
||||
}
|
||||
let mut current = root;
|
||||
for part in reference.trim_start_matches("#/").split('/') {
|
||||
let part = part.replace("~1", "/").replace("~0", "~");
|
||||
current = current.get(&part)?;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
use crank_core::HttpMethod;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::rest::{
|
||||
model::{
|
||||
ImportFinding, RestImportDocument, RestImportOperation, RestImportParameter,
|
||||
RestParameterLocation,
|
||||
},
|
||||
normalize::{ImportParseError, resolve_local_ref},
|
||||
recommendations::document_finding,
|
||||
};
|
||||
|
||||
pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseError> {
|
||||
let version = root
|
||||
.get("openapi")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let title = root
|
||||
.pointer("/info/title")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Imported API")
|
||||
.to_owned();
|
||||
let servers = root
|
||||
.get("servers")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.get("url").and_then(Value::as_str))
|
||||
.map(|url| url.trim_end_matches('/').to_owned())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut findings = Vec::new();
|
||||
if servers.is_empty() {
|
||||
findings.push(document_finding(
|
||||
"missing_servers",
|
||||
"В документе не указаны servers, base URL нужно будет выбрать вручную.",
|
||||
));
|
||||
} else if servers.len() > 1 {
|
||||
findings.push(document_finding(
|
||||
"multiple_servers",
|
||||
"В документе несколько servers, при импорте нужно выбрать нужный base URL.",
|
||||
));
|
||||
}
|
||||
|
||||
let mut operations = Vec::new();
|
||||
let paths = root
|
||||
.get("paths")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or(ImportParseError::UnsupportedDocument)?;
|
||||
|
||||
for (path, path_item) in paths {
|
||||
let path_parameters = parameters(root, path_item.get("parameters"));
|
||||
for method_name in ["get", "post", "put", "patch", "delete"] {
|
||||
let Some(operation_value) = path_item.get(method_name) else {
|
||||
continue;
|
||||
};
|
||||
let Some(method) = method_from_lower(method_name) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut operation_parameters = path_parameters.clone();
|
||||
operation_parameters.extend(parameters(root, operation_value.get("parameters")));
|
||||
let operation_servers = operation_value
|
||||
.get("servers")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.get("url").and_then(Value::as_str))
|
||||
.map(|url| url.trim_end_matches('/').to_owned())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
operations.push(RestImportOperation {
|
||||
key: format!("{} {}", method_name.to_uppercase(), path),
|
||||
method,
|
||||
path: path.clone(),
|
||||
operation_id: operation_value
|
||||
.get("operationId")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
summary: operation_value
|
||||
.get("summary")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
description: operation_value
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
tags: operation_value
|
||||
.get("tags")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
parameters: operation_parameters,
|
||||
request_body_schema: request_body_schema(root, operation_value),
|
||||
response_schema: response_schema(root, operation_value),
|
||||
servers: operation_servers,
|
||||
findings: operation_findings(operation_value),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RestImportDocument {
|
||||
format: "openapi".to_owned(),
|
||||
version,
|
||||
title,
|
||||
servers,
|
||||
operations,
|
||||
findings,
|
||||
})
|
||||
}
|
||||
|
||||
fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
|
||||
value
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let item = resolve_local_ref(root, item, 0);
|
||||
let location = match item.get("in").and_then(Value::as_str)? {
|
||||
"path" => RestParameterLocation::Path,
|
||||
"query" => RestParameterLocation::Query,
|
||||
"header" => RestParameterLocation::Header,
|
||||
_ => return None,
|
||||
};
|
||||
Some(RestImportParameter {
|
||||
name: item.get("name").and_then(Value::as_str)?.to_owned(),
|
||||
location,
|
||||
required: item
|
||||
.get("required")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
|| location == RestParameterLocation::Path,
|
||||
description: item
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
schema: item
|
||||
.get("schema")
|
||||
.map(|schema| resolve_local_ref(root, schema, 0)),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn request_body_schema(root: &Value, operation: &Value) -> Option<Value> {
|
||||
let body = resolve_local_ref(root, operation.get("requestBody")?, 0);
|
||||
let content = body.get("content")?.as_object()?;
|
||||
for content_type in ["application/json", "application/*+json"] {
|
||||
if let Some(schema) = content
|
||||
.get(content_type)
|
||||
.and_then(|media| media.get("schema"))
|
||||
{
|
||||
return Some(resolve_local_ref(root, schema, 0));
|
||||
}
|
||||
}
|
||||
content
|
||||
.iter()
|
||||
.find(|(content_type, _)| content_type.contains("json"))
|
||||
.and_then(|(_, media)| media.get("schema"))
|
||||
.map(|schema| resolve_local_ref(root, schema, 0))
|
||||
}
|
||||
|
||||
fn response_schema(root: &Value, operation: &Value) -> Option<Value> {
|
||||
let responses = operation.get("responses")?.as_object()?;
|
||||
for code in ["200", "201", "202", "default"] {
|
||||
let Some(response) = responses.get(code) else {
|
||||
continue;
|
||||
};
|
||||
let response = resolve_local_ref(root, response, 0);
|
||||
let Some(content) = response.get("content").and_then(Value::as_object) else {
|
||||
continue;
|
||||
};
|
||||
for content_type in ["application/json", "application/*+json"] {
|
||||
if let Some(schema) = content
|
||||
.get(content_type)
|
||||
.and_then(|media| media.get("schema"))
|
||||
{
|
||||
return Some(resolve_local_ref(root, schema, 0));
|
||||
}
|
||||
}
|
||||
if let Some(schema) = content
|
||||
.iter()
|
||||
.find(|(content_type, _)| content_type.contains("json"))
|
||||
.and_then(|(_, media)| media.get("schema"))
|
||||
{
|
||||
return Some(resolve_local_ref(root, schema, 0));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn operation_findings(operation: &Value) -> Vec<ImportFinding> {
|
||||
let mut findings = Vec::new();
|
||||
if operation.get("requestBody").is_some()
|
||||
&& request_body_schema(&Value::Null, operation).is_none()
|
||||
{
|
||||
findings.push(document_finding(
|
||||
"unsupported_request_body",
|
||||
"У метода есть requestBody, но JSON schema не найдена.",
|
||||
));
|
||||
}
|
||||
findings
|
||||
}
|
||||
|
||||
fn method_from_lower(value: &str) -> Option<HttpMethod> {
|
||||
match value {
|
||||
"get" => Some(HttpMethod::Get),
|
||||
"post" => Some(HttpMethod::Post),
|
||||
"put" => Some(HttpMethod::Put),
|
||||
"patch" => Some(HttpMethod::Patch),
|
||||
"delete" => Some(HttpMethod::Delete),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
|
||||
use crate::rest::model::{ImportFinding, ImportFindingSeverity, RestImportOperation};
|
||||
|
||||
pub fn document_finding(code: &str, message: impl Into<String>) -> ImportFinding {
|
||||
ImportFinding {
|
||||
code: code.to_owned(),
|
||||
severity: ImportFindingSeverity::Warning,
|
||||
message: message.into(),
|
||||
operation_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn operation_finding(
|
||||
operation_key: &str,
|
||||
code: &str,
|
||||
message: impl Into<String>,
|
||||
) -> ImportFinding {
|
||||
ImportFinding {
|
||||
code: code.to_owned(),
|
||||
severity: ImportFindingSeverity::Warning,
|
||||
message: message.into(),
|
||||
operation_key: Some(operation_key.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn operation_info(
|
||||
operation_key: &str,
|
||||
code: &str,
|
||||
message: impl Into<String>,
|
||||
) -> ImportFinding {
|
||||
ImportFinding {
|
||||
code: code.to_owned(),
|
||||
severity: ImportFindingSeverity::Info,
|
||||
message: message.into(),
|
||||
operation_key: Some(operation_key.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn operation_recommendations(operation: &RestImportOperation) -> Vec<ImportFinding> {
|
||||
let mut findings = operation.findings.clone();
|
||||
if operation
|
||||
.operation_id
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
findings.push(operation_finding(
|
||||
&operation.key,
|
||||
"missing_operation_id",
|
||||
"У метода нет operationId, имя инструмента будет сгенерировано из метода и пути.",
|
||||
));
|
||||
}
|
||||
if operation
|
||||
.summary
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
findings.push(operation_finding(
|
||||
&operation.key,
|
||||
"missing_summary",
|
||||
"У метода нет summary, отображаемое имя будет сгенерировано автоматически.",
|
||||
));
|
||||
}
|
||||
if operation
|
||||
.description
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.is_empty()
|
||||
{
|
||||
findings.push(operation_finding(
|
||||
&operation.key,
|
||||
"missing_description",
|
||||
"У метода нет description. Перед публикацией лучше описать, когда агенту стоит вызывать этот инструмент.",
|
||||
));
|
||||
}
|
||||
if operation.response_schema.is_none() {
|
||||
findings.push(operation_finding(
|
||||
&operation.key,
|
||||
"missing_response_schema",
|
||||
"У метода не найдена схема успешного ответа, результат будет описан как общий объект.",
|
||||
));
|
||||
}
|
||||
if operation.parameters.len() > 12 {
|
||||
findings.push(operation_finding(
|
||||
&operation.key,
|
||||
"too_many_inputs",
|
||||
"У метода много входных параметров. Проверьте, не стоит ли разделить инструмент на более узкие сценарии.",
|
||||
));
|
||||
}
|
||||
let undocumented_parameters = operation
|
||||
.parameters
|
||||
.iter()
|
||||
.filter(|parameter| {
|
||||
parameter
|
||||
.description
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.is_empty()
|
||||
})
|
||||
.count();
|
||||
if undocumented_parameters > 0 {
|
||||
findings.push(operation_finding(
|
||||
&operation.key,
|
||||
"parameter_descriptions_missing",
|
||||
format!(
|
||||
"У {undocumented_parameters} входных параметров нет описания. Модели будет сложнее понять, какие значения туда передавать."
|
||||
),
|
||||
));
|
||||
}
|
||||
findings
|
||||
}
|
||||
|
||||
pub fn candidate_recommendations(
|
||||
operation: &RestImportOperation,
|
||||
suggested_name: &str,
|
||||
description: &str,
|
||||
input_schema: &Schema,
|
||||
output_schema: &Schema,
|
||||
) -> Vec<ImportFinding> {
|
||||
let mut findings = Vec::new();
|
||||
let input_fields = field_count(input_schema);
|
||||
let output_fields = field_count(output_schema);
|
||||
|
||||
if input_fields == 0 {
|
||||
findings.push(operation_info(
|
||||
&operation.key,
|
||||
"no_input_fields",
|
||||
"У инструмента нет входных параметров. Это нормально для справочных методов, но проверьте, что агенту не нужно передавать фильтры.",
|
||||
));
|
||||
}
|
||||
if output_fields == 0 {
|
||||
findings.push(operation_finding(
|
||||
&operation.key,
|
||||
"empty_output_schema",
|
||||
"В ответе не найдено отдельных полей. Перед публикацией проверьте схему ответа и маппинг результата.",
|
||||
));
|
||||
}
|
||||
if output_fields > 12 {
|
||||
findings.push(operation_finding(
|
||||
&operation.key,
|
||||
"too_many_output_fields",
|
||||
format!(
|
||||
"В ответ инструмента попадает много полей: {output_fields}. Лучше вернуть только данные, которые нужны агенту для ответа пользователю."
|
||||
),
|
||||
));
|
||||
}
|
||||
if is_weak_description(operation, description) {
|
||||
findings.push(operation_finding(
|
||||
&operation.key,
|
||||
"weak_tool_description",
|
||||
"Описание инструмента слишком короткое или техническое. Перед публикацией добавьте, когда агент должен вызывать инструмент и что будет в успешном ответе.",
|
||||
));
|
||||
}
|
||||
if weak_name(suggested_name) {
|
||||
findings.push(operation_finding(
|
||||
&operation.key,
|
||||
"weak_tool_name",
|
||||
format!(
|
||||
"Имя инструмента `{suggested_name}` выглядит слишком общим. Лучше использовать имя с конкретным действием и объектом."
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
findings
|
||||
}
|
||||
|
||||
fn is_weak_description(operation: &RestImportOperation, description: &str) -> bool {
|
||||
let normalized = description.trim();
|
||||
if normalized.chars().count() < 40 {
|
||||
return true;
|
||||
}
|
||||
if normalized.starts_with("Выполняет ") {
|
||||
return true;
|
||||
}
|
||||
operation
|
||||
.summary
|
||||
.as_deref()
|
||||
.map(|summary| summary.trim() == normalized)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn weak_name(name: &str) -> bool {
|
||||
let parts = name
|
||||
.split('_')
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if parts.len() < 2 {
|
||||
return true;
|
||||
}
|
||||
let weak_terms = [
|
||||
"api",
|
||||
"data",
|
||||
"item",
|
||||
"items",
|
||||
"object",
|
||||
"operation",
|
||||
"request",
|
||||
];
|
||||
parts.iter().any(|part| weak_terms.contains(part))
|
||||
}
|
||||
|
||||
fn field_count(schema: &Schema) -> usize {
|
||||
match schema.kind {
|
||||
SchemaKind::Object => schema.fields.len(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn any_object(description: Option<String>) -> Schema {
|
||||
Schema {
|
||||
kind: SchemaKind::Object,
|
||||
description,
|
||||
required: true,
|
||||
nullable: false,
|
||||
default_value: None,
|
||||
fields: BTreeMap::new(),
|
||||
items: None,
|
||||
enum_values: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn field_count(schema: &Schema) -> usize {
|
||||
match schema.kind {
|
||||
SchemaKind::Object => schema.fields.len(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schema_from_openapi(
|
||||
value: Option<&Value>,
|
||||
required: bool,
|
||||
description: Option<String>,
|
||||
) -> Schema {
|
||||
let Some(value) = value else {
|
||||
return primitive(SchemaKind::String, required, description);
|
||||
};
|
||||
let resolved = collapse_composition(value);
|
||||
|
||||
if let Some(values) = resolved.get("enum").and_then(Value::as_array) {
|
||||
let enum_values = values
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
if !enum_values.is_empty() {
|
||||
return Schema {
|
||||
kind: SchemaKind::Enum,
|
||||
description: description.or_else(|| text(&resolved, "description")),
|
||||
required,
|
||||
nullable: nullable(&resolved),
|
||||
default_value: resolved.get("default").cloned(),
|
||||
fields: BTreeMap::new(),
|
||||
items: None,
|
||||
enum_values,
|
||||
variants: Vec::new(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
match type_name(&resolved).unwrap_or("object") {
|
||||
"object" => object_schema(&resolved, required, description),
|
||||
"array" => Schema {
|
||||
kind: SchemaKind::Array,
|
||||
description: description.or_else(|| text(&resolved, "description")),
|
||||
required,
|
||||
nullable: nullable(&resolved),
|
||||
default_value: resolved.get("default").cloned(),
|
||||
fields: BTreeMap::new(),
|
||||
items: Some(Box::new(schema_from_openapi(
|
||||
resolved.get("items"),
|
||||
true,
|
||||
None,
|
||||
))),
|
||||
enum_values: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
},
|
||||
"integer" => primitive(
|
||||
SchemaKind::Integer,
|
||||
required,
|
||||
description.or_else(|| text(&resolved, "description")),
|
||||
),
|
||||
"number" => primitive(
|
||||
SchemaKind::Number,
|
||||
required,
|
||||
description.or_else(|| text(&resolved, "description")),
|
||||
),
|
||||
"boolean" => primitive(
|
||||
SchemaKind::Boolean,
|
||||
required,
|
||||
description.or_else(|| text(&resolved, "description")),
|
||||
),
|
||||
"null" => primitive(
|
||||
SchemaKind::Null,
|
||||
required,
|
||||
description.or_else(|| text(&resolved, "description")),
|
||||
),
|
||||
_ => primitive(
|
||||
SchemaKind::String,
|
||||
required,
|
||||
description.or_else(|| text(&resolved, "description")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn object_with_fields(description: Option<String>, fields: BTreeMap<String, Schema>) -> Schema {
|
||||
Schema {
|
||||
kind: SchemaKind::Object,
|
||||
description,
|
||||
required: true,
|
||||
nullable: false,
|
||||
default_value: None,
|
||||
fields,
|
||||
items: None,
|
||||
enum_values: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn object_schema(value: &Value, required: bool, description: Option<String>) -> Schema {
|
||||
let required_fields = value
|
||||
.get("required")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut fields = BTreeMap::new();
|
||||
|
||||
if let Some(properties) = value.get("properties").and_then(Value::as_object) {
|
||||
for (name, schema) in properties {
|
||||
fields.insert(
|
||||
name.clone(),
|
||||
schema_from_openapi(
|
||||
Some(schema),
|
||||
required_fields.contains(name.as_str()),
|
||||
text(schema, "description"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Schema {
|
||||
kind: SchemaKind::Object,
|
||||
description: description.or_else(|| text(value, "description")),
|
||||
required,
|
||||
nullable: nullable(value),
|
||||
default_value: value.get("default").cloned(),
|
||||
fields,
|
||||
items: None,
|
||||
enum_values: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn primitive(kind: SchemaKind, required: bool, description: Option<String>) -> Schema {
|
||||
Schema {
|
||||
kind,
|
||||
description,
|
||||
required,
|
||||
nullable: false,
|
||||
default_value: None,
|
||||
fields: BTreeMap::new(),
|
||||
items: None,
|
||||
enum_values: Vec::new(),
|
||||
variants: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn type_name(value: &Value) -> Option<&str> {
|
||||
match value.get("type") {
|
||||
Some(Value::String(value)) => Some(value.as_str()),
|
||||
Some(Value::Array(values)) => values.iter().find_map(Value::as_str),
|
||||
_ if value.get("properties").is_some() => Some("object"),
|
||||
_ if value.get("items").is_some() => Some("array"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn nullable(value: &Value) -> bool {
|
||||
value
|
||||
.get("nullable")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn text(value: &Value, key: &str) -> Option<String> {
|
||||
value
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn collapse_composition(value: &Value) -> Value {
|
||||
for key in ["allOf", "oneOf", "anyOf"] {
|
||||
if let Some(items) = value.get(key).and_then(Value::as_array) {
|
||||
if let Some(first) = items.first() {
|
||||
return first.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
value.clone()
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use crank_core::HttpMethod;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::rest::{
|
||||
model::{RestImportDocument, RestImportOperation, RestImportParameter, RestParameterLocation},
|
||||
normalize::{ImportParseError, resolve_local_ref},
|
||||
recommendations::{document_finding, operation_finding},
|
||||
};
|
||||
|
||||
pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseError> {
|
||||
let title = root
|
||||
.pointer("/info/title")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Imported API")
|
||||
.to_owned();
|
||||
let servers = swagger_servers(root);
|
||||
let mut findings = Vec::new();
|
||||
if servers.is_empty() {
|
||||
findings.push(document_finding(
|
||||
"missing_servers",
|
||||
"В Swagger 2.0 документе не указаны host/schemes, base URL нужно будет выбрать вручную.",
|
||||
));
|
||||
}
|
||||
|
||||
let mut operations = Vec::new();
|
||||
let paths = root
|
||||
.get("paths")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or(ImportParseError::UnsupportedDocument)?;
|
||||
|
||||
for (path, path_item) in paths {
|
||||
let path_parameters = parameters(root, path_item.get("parameters"));
|
||||
for method_name in ["get", "post", "put", "patch", "delete"] {
|
||||
let Some(operation_value) = path_item.get(method_name) else {
|
||||
continue;
|
||||
};
|
||||
let Some(method) = method_from_lower(method_name) else {
|
||||
continue;
|
||||
};
|
||||
let mut operation_parameters = path_parameters.clone();
|
||||
operation_parameters.extend(parameters(root, operation_value.get("parameters")));
|
||||
let request_body_schema = operation_parameters
|
||||
.iter()
|
||||
.find(|parameter| parameter.name == "body")
|
||||
.and_then(|parameter| parameter.schema.clone());
|
||||
let operation_parameters = operation_parameters
|
||||
.into_iter()
|
||||
.filter(|parameter| parameter.name != "body")
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
operations.push(RestImportOperation {
|
||||
key: format!("{} {}", method_name.to_uppercase(), path),
|
||||
method,
|
||||
path: path.clone(),
|
||||
operation_id: operation_value
|
||||
.get("operationId")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
summary: operation_value
|
||||
.get("summary")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
description: operation_value
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
tags: operation_value
|
||||
.get("tags")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
parameters: operation_parameters,
|
||||
request_body_schema,
|
||||
response_schema: response_schema(root, operation_value),
|
||||
servers: Vec::new(),
|
||||
findings: swagger_operation_findings(path, operation_value),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(RestImportDocument {
|
||||
format: "swagger".to_owned(),
|
||||
version: Some("2.0".to_owned()),
|
||||
title,
|
||||
servers,
|
||||
operations,
|
||||
findings,
|
||||
})
|
||||
}
|
||||
|
||||
fn swagger_servers(root: &Value) -> Vec<String> {
|
||||
let Some(host) = root.get("host").and_then(Value::as_str) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let base_path = root.get("basePath").and_then(Value::as_str).unwrap_or("");
|
||||
let schemes = root
|
||||
.get("schemes")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.iter().filter_map(Value::as_str).collect::<Vec<_>>())
|
||||
.filter(|items| !items.is_empty())
|
||||
.unwrap_or_else(|| vec!["https"]);
|
||||
|
||||
schemes
|
||||
.into_iter()
|
||||
.map(|scheme| {
|
||||
format!("{scheme}://{host}{base_path}")
|
||||
.trim_end_matches('/')
|
||||
.to_owned()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
|
||||
value
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let item = resolve_local_ref(root, item, 0);
|
||||
let raw_location = item.get("in").and_then(Value::as_str)?;
|
||||
if raw_location == "body" {
|
||||
return Some(RestImportParameter {
|
||||
name: "body".to_owned(),
|
||||
location: RestParameterLocation::Query,
|
||||
required: item
|
||||
.get("required")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
description: item
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
schema: item
|
||||
.get("schema")
|
||||
.map(|schema| resolve_local_ref(root, schema, 0)),
|
||||
});
|
||||
}
|
||||
let location = match raw_location {
|
||||
"path" => RestParameterLocation::Path,
|
||||
"query" => RestParameterLocation::Query,
|
||||
"header" => RestParameterLocation::Header,
|
||||
_ => return None,
|
||||
};
|
||||
Some(RestImportParameter {
|
||||
name: item.get("name").and_then(Value::as_str)?.to_owned(),
|
||||
location,
|
||||
required: item
|
||||
.get("required")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
|| location == RestParameterLocation::Path,
|
||||
description: item
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
schema: swagger_parameter_schema(root, &item),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn swagger_parameter_schema(root: &Value, parameter: &Value) -> Option<Value> {
|
||||
if let Some(schema) = parameter.get("schema") {
|
||||
return Some(resolve_local_ref(root, schema, 0));
|
||||
}
|
||||
let mut schema = serde_json::Map::new();
|
||||
for key in ["type", "format", "items", "enum", "default", "description"] {
|
||||
if let Some(value) = parameter.get(key) {
|
||||
schema.insert(key.to_owned(), resolve_local_ref(root, value, 0));
|
||||
}
|
||||
}
|
||||
if schema.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Object(schema))
|
||||
}
|
||||
}
|
||||
|
||||
fn response_schema(root: &Value, operation: &Value) -> Option<Value> {
|
||||
let responses = operation.get("responses")?.as_object()?;
|
||||
for code in ["200", "201", "202", "default"] {
|
||||
let Some(response) = responses.get(code) else {
|
||||
continue;
|
||||
};
|
||||
let response = resolve_local_ref(root, response, 0);
|
||||
if let Some(schema) = response.get("schema") {
|
||||
return Some(resolve_local_ref(root, schema, 0));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn swagger_operation_findings(
|
||||
path: &str,
|
||||
operation: &Value,
|
||||
) -> Vec<crate::rest::model::ImportFinding> {
|
||||
let mut findings = Vec::new();
|
||||
if operation
|
||||
.get("consumes")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|items| {
|
||||
!items
|
||||
.iter()
|
||||
.any(|item| item.as_str().is_some_and(|value| value.contains("json")))
|
||||
})
|
||||
{
|
||||
findings.push(operation_finding(
|
||||
path,
|
||||
"unsupported_consumes",
|
||||
"Метод использует content type без JSON. Проверьте настройки тела запроса после импорта.",
|
||||
));
|
||||
}
|
||||
findings
|
||||
}
|
||||
|
||||
fn method_from_lower(value: &str) -> Option<HttpMethod> {
|
||||
match value {
|
||||
"get" => Some(HttpMethod::Get),
|
||||
"post" => Some(HttpMethod::Post),
|
||||
"put" => Some(HttpMethod::Put),
|
||||
"patch" => Some(HttpMethod::Patch),
|
||||
"delete" => Some(HttpMethod::Delete),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user