Compare commits
4 Commits
87cdb95b80
...
42dd796927
| Author | SHA1 | Date | |
|---|---|---|---|
| 42dd796927 | |||
| 04f63e690c | |||
| 052b9356ec | |||
| a8aa3248c9 |
Generated
+14
@@ -13,6 +13,7 @@ dependencies = [
|
||||
"base64",
|
||||
"crank-community-auth",
|
||||
"crank-core",
|
||||
"crank-import",
|
||||
"crank-mapping",
|
||||
"crank-registry",
|
||||
"crank-runtime",
|
||||
@@ -596,6 +597,19 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crank-import"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"crank-core",
|
||||
"crank-mapping",
|
||||
"crank-schema",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crank-mapping"
|
||||
version = "0.3.1"
|
||||
|
||||
@@ -5,6 +5,7 @@ members = [
|
||||
"crates/crank-community-auth",
|
||||
"crates/crank-community-mcp",
|
||||
"crates/crank-core",
|
||||
"crates/crank-import",
|
||||
"crates/crank-schema",
|
||||
"crates/crank-mapping",
|
||||
"crates/crank-registry",
|
||||
|
||||
@@ -16,6 +16,7 @@ axum-extra.workspace = true
|
||||
base64.workspace = true
|
||||
crank-community-auth = { path = "../../crates/crank-community-auth" }
|
||||
crank-core = { path = "../../crates/crank-core" }
|
||||
crank-import = { path = "../../crates/crank-import" }
|
||||
crank-mapping = { path = "../../crates/crank-mapping" }
|
||||
crank-registry = { path = "../../crates/crank-registry" }
|
||||
crank-runtime = { path = "../../crates/crank-runtime" }
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::{
|
||||
auth::{change_password, get_profile, get_session, login, logout, update_profile},
|
||||
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
|
||||
capabilities::get_capabilities,
|
||||
imports::{create_openapi_import, preview_openapi_import},
|
||||
observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs},
|
||||
operations::{
|
||||
analyze_operation_quality, archive_operation, create_operation, create_version,
|
||||
@@ -35,6 +36,11 @@ use crate::{
|
||||
pub fn build_app(state: AppState) -> Router {
|
||||
let workspace_router = Router::new()
|
||||
.route("/operations", get(list_operations).post(create_operation))
|
||||
.route("/imports/openapi/preview", post(preview_openapi_import))
|
||||
.route(
|
||||
"/imports/openapi/{job_id}/create",
|
||||
post(create_openapi_import),
|
||||
)
|
||||
.route(
|
||||
"/operations/analyze-quality",
|
||||
post(analyze_operation_quality),
|
||||
|
||||
@@ -309,6 +309,48 @@ pub struct YamlOperationDocument {
|
||||
pub operation: RegistryOperation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct OpenApiImportPreviewPayload {
|
||||
pub document: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct OpenApiImportPreviewResponse {
|
||||
pub job_id: String,
|
||||
pub expires_at: String,
|
||||
pub preview: crank_import::rest::ImportPreview,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct OpenApiImportCreatePayload {
|
||||
pub selected_operation_keys: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub server_url: Option<String>,
|
||||
#[serde(default = "default_openapi_conflict_mode")]
|
||||
pub conflict_mode: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct OpenApiImportCreateResponse {
|
||||
pub created: Vec<OpenApiImportCreatedOperation>,
|
||||
pub skipped: Vec<OpenApiImportSkippedOperation>,
|
||||
pub findings: Vec<crank_import::rest::ImportFinding>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct OpenApiImportCreatedOperation {
|
||||
pub operation_id: String,
|
||||
pub name: String,
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct OpenApiImportSkippedOperation {
|
||||
pub operation_key: String,
|
||||
pub name: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CreatedOperationResponse {
|
||||
pub operation_id: String,
|
||||
@@ -433,6 +475,10 @@ fn default_operation_category() -> String {
|
||||
"general".to_owned()
|
||||
}
|
||||
|
||||
fn default_openapi_conflict_mode() -> String {
|
||||
"rename".to_owned()
|
||||
}
|
||||
|
||||
fn default_export_mode() -> ExportMode {
|
||||
ExportMode::Portable
|
||||
}
|
||||
|
||||
@@ -347,6 +347,10 @@ impl From<RegistryError> for ApiError {
|
||||
format!("yaml import job {job_id} was not found"),
|
||||
json!({ "job_id": job_id }),
|
||||
),
|
||||
RegistryError::ImportJobNotFound { job_id } => Self::not_found_with_context(
|
||||
format!("import job {job_id} was not found"),
|
||||
json!({ "job_id": job_id }),
|
||||
),
|
||||
RegistryError::Storage(_) | RegistryError::Serialization(_) => {
|
||||
Self::internal(value.to_string())
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod agents;
|
||||
pub mod auth;
|
||||
pub mod auth_profiles;
|
||||
pub mod capabilities;
|
||||
pub mod imports;
|
||||
pub mod observability;
|
||||
pub mod operations;
|
||||
pub mod secrets;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{OpenApiImportCreatePayload, OpenApiImportPreviewPayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspacePath {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WorkspaceImportPath {
|
||||
pub workspace_id: String,
|
||||
pub job_id: String,
|
||||
}
|
||||
|
||||
pub async fn preview_openapi_import(
|
||||
Path(path): Path<WorkspacePath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<OpenApiImportPreviewPayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let preview = state
|
||||
.service
|
||||
.preview_openapi_import(&path.workspace_id.as_str().into(), payload)
|
||||
.await?;
|
||||
Ok(Json(json!(preview)))
|
||||
}
|
||||
|
||||
pub async fn create_openapi_import(
|
||||
Path(path): Path<WorkspaceImportPath>,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<OpenApiImportCreatePayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let imported = state
|
||||
.service
|
||||
.create_openapi_import(
|
||||
&path.workspace_id.as_str().into(),
|
||||
&path.job_id.as_str().into(),
|
||||
payload,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(json!(imported)))
|
||||
}
|
||||
@@ -27,6 +27,7 @@ mod api_keys;
|
||||
mod auth;
|
||||
mod demo;
|
||||
mod import_export;
|
||||
mod imports;
|
||||
mod observability;
|
||||
mod operation_validation;
|
||||
mod operations;
|
||||
|
||||
@@ -392,6 +392,7 @@ fn demo_rest_operation_payload() -> OperationPayload {
|
||||
input_sample: Some(input),
|
||||
output_sample: Some(output),
|
||||
test_input: Some(demo_rest_input_sample()),
|
||||
import_findings: Vec::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crank_core::{
|
||||
ExecutionConfig, OperationSecurityLevel, Protocol, ToolQualityFinding, ToolQualitySeverity,
|
||||
WorkspaceId,
|
||||
};
|
||||
use crank_import::rest::{
|
||||
ImportFinding, ImportFindingSeverity, ImportOperationCandidate, operation_draft_from_candidate,
|
||||
};
|
||||
use crank_registry::{
|
||||
CreateImportJobRequest, FinishImportJobRequest, ImportJobId, ImportJobKind, ImportJobStatus,
|
||||
};
|
||||
use serde_json::json;
|
||||
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tracing::{info, instrument};
|
||||
|
||||
use crate::{
|
||||
error::ApiError,
|
||||
service::{
|
||||
AdminService, OpenApiImportCreatePayload, OpenApiImportCreateResponse,
|
||||
OpenApiImportCreatedOperation, OpenApiImportPreviewPayload, OpenApiImportPreviewResponse,
|
||||
OpenApiImportSkippedOperation, OperationPayload, new_prefixed_id,
|
||||
},
|
||||
};
|
||||
|
||||
const IMPORT_JOB_TTL_HOURS: i64 = 24;
|
||||
|
||||
impl AdminService {
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str()))]
|
||||
pub async fn preview_openapi_import(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
payload: OpenApiImportPreviewPayload,
|
||||
) -> Result<OpenApiImportPreviewResponse, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let _ = self.registry.delete_expired_import_jobs().await;
|
||||
|
||||
let preview = crank_import::rest::preview_document(&payload.document)
|
||||
.map_err(|error| ApiError::validation(error.to_string()))?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let expires_at = now + Duration::hours(IMPORT_JOB_TTL_HOURS);
|
||||
let job_id = ImportJobId::new(new_prefixed_id("imp"));
|
||||
let preview_payload = serde_json::to_value(&preview)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||
|
||||
self.registry
|
||||
.create_import_job(CreateImportJobRequest {
|
||||
id: &job_id,
|
||||
workspace_id,
|
||||
kind: ImportJobKind::OpenApi,
|
||||
source_format: &preview.source.format,
|
||||
source_version: preview.source.version.as_deref(),
|
||||
status: ImportJobStatus::Completed,
|
||||
preview_payload: &preview_payload,
|
||||
created_at: &now,
|
||||
expires_at: &expires_at,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(OpenApiImportPreviewResponse {
|
||||
job_id: job_id.as_str().to_owned(),
|
||||
expires_at: expires_at
|
||||
.format(&Rfc3339)
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?,
|
||||
preview,
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), job_id = %job_id.as_str()))]
|
||||
pub async fn create_openapi_import(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
job_id: &ImportJobId,
|
||||
payload: OpenApiImportCreatePayload,
|
||||
) -> Result<OpenApiImportCreateResponse, ApiError> {
|
||||
self.ensure_workspace_exists(workspace_id).await?;
|
||||
let _ = self.registry.delete_expired_import_jobs().await;
|
||||
|
||||
if !matches!(payload.conflict_mode.as_str(), "skip" | "rename") {
|
||||
return Err(ApiError::validation(
|
||||
"unsupported conflict_mode; supported values are skip and rename",
|
||||
));
|
||||
}
|
||||
|
||||
let job = self
|
||||
.registry
|
||||
.get_import_job(workspace_id, job_id)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
ApiError::not_found_with_context(
|
||||
"import job was not found",
|
||||
json!({ "job_id": job_id.as_str() }),
|
||||
)
|
||||
})?;
|
||||
if job.expires_at <= OffsetDateTime::now_utc() {
|
||||
return Err(ApiError::validation("import preview has expired"));
|
||||
}
|
||||
if job.kind != ImportJobKind::OpenApi {
|
||||
return Err(ApiError::validation("import job kind is not openapi"));
|
||||
}
|
||||
|
||||
let preview: crank_import::rest::ImportPreview =
|
||||
serde_json::from_value(job.preview_payload.clone())
|
||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||
let selected = payload
|
||||
.selected_operation_keys
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
if selected.is_empty() {
|
||||
return Err(ApiError::validation(
|
||||
"selected_operation_keys must contain at least one operation",
|
||||
));
|
||||
}
|
||||
|
||||
let mut candidates = BTreeMap::new();
|
||||
for group in &preview.groups {
|
||||
for operation in &group.operations {
|
||||
candidates.insert(operation.key.clone(), operation);
|
||||
}
|
||||
}
|
||||
|
||||
let mut created = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
let mut findings = Vec::new();
|
||||
let mut created_ids = Vec::new();
|
||||
|
||||
for operation_key in selected {
|
||||
let Some(candidate) = candidates.get(&operation_key) else {
|
||||
skipped.push(OpenApiImportSkippedOperation {
|
||||
operation_key,
|
||||
name: String::new(),
|
||||
reason: "operation was not found in import preview".to_owned(),
|
||||
});
|
||||
continue;
|
||||
};
|
||||
let mut draft =
|
||||
operation_draft_from_candidate(candidate, payload.server_url.as_deref());
|
||||
attach_import_findings(&mut draft, candidate);
|
||||
if let Some(existing_name) = self
|
||||
.find_operation_by_name(workspace_id, &draft.name)
|
||||
.await?
|
||||
.map(|operation| operation.name)
|
||||
{
|
||||
if payload.conflict_mode == "skip" {
|
||||
skipped.push(OpenApiImportSkippedOperation {
|
||||
operation_key: candidate.key.clone(),
|
||||
name: draft.name.clone(),
|
||||
reason: "operation with this name already exists".to_owned(),
|
||||
});
|
||||
findings.push(ImportFinding {
|
||||
code: "operation_name_conflict".to_owned(),
|
||||
severity: ImportFindingSeverity::Warning,
|
||||
message: format!(
|
||||
"Операция {} уже существует и была пропущена.",
|
||||
draft.name
|
||||
),
|
||||
operation_key: Some(candidate.key.clone()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let renamed = self
|
||||
.next_available_operation_name(workspace_id, &draft.name)
|
||||
.await?;
|
||||
findings.push(ImportFinding {
|
||||
code: "operation_name_renamed".to_owned(),
|
||||
severity: ImportFindingSeverity::Info,
|
||||
message: format!(
|
||||
"Операция {existing_name} уже существует, новый черновик создан как {renamed}."
|
||||
),
|
||||
operation_key: Some(candidate.key.clone()),
|
||||
});
|
||||
draft.name = renamed;
|
||||
}
|
||||
|
||||
let payload = OperationPayload {
|
||||
name: draft.name.clone(),
|
||||
display_name: draft.display_name.clone(),
|
||||
category: draft.category,
|
||||
protocol: Protocol::Rest,
|
||||
security_level: OperationSecurityLevel::Standard,
|
||||
target: crank_core::Target::Rest(draft.target),
|
||||
input_schema: draft.input_schema,
|
||||
output_schema: draft.output_schema,
|
||||
input_mapping: draft.input_mapping,
|
||||
output_mapping: draft.output_mapping,
|
||||
execution_config: ExecutionConfig {
|
||||
timeout_ms: 10_000,
|
||||
retry_policy: None,
|
||||
response_cache: None,
|
||||
idempotency: None,
|
||||
safety: None,
|
||||
auth_profile_ref: None,
|
||||
headers: BTreeMap::new(),
|
||||
},
|
||||
tool_description: draft.tool_description,
|
||||
wizard_state: draft.wizard_state,
|
||||
};
|
||||
let result = self.create_operation(workspace_id, payload).await?;
|
||||
created_ids.push(result.operation_id.clone());
|
||||
created.push(OpenApiImportCreatedOperation {
|
||||
operation_id: result.operation_id,
|
||||
name: draft.name,
|
||||
version: result.version,
|
||||
});
|
||||
}
|
||||
|
||||
let finished_at = OffsetDateTime::now_utc();
|
||||
self.registry
|
||||
.finish_import_job(FinishImportJobRequest {
|
||||
id: job_id,
|
||||
status: ImportJobStatus::Completed,
|
||||
created_operation_ids: &json!(created_ids),
|
||||
error_text: None,
|
||||
finished_at: &finished_at,
|
||||
})
|
||||
.await?;
|
||||
info!(
|
||||
created = created.len(),
|
||||
skipped = skipped.len(),
|
||||
"openapi import created drafts"
|
||||
);
|
||||
|
||||
Ok(OpenApiImportCreateResponse {
|
||||
created,
|
||||
skipped,
|
||||
findings,
|
||||
})
|
||||
}
|
||||
|
||||
async fn next_available_operation_name(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
base_name: &str,
|
||||
) -> Result<String, ApiError> {
|
||||
for index in 2.. {
|
||||
let candidate = format!("{base_name}_{index}");
|
||||
if self
|
||||
.find_operation_by_name(workspace_id, &candidate)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
|
||||
fn attach_import_findings(
|
||||
draft: &mut crank_import::rest::RestImportCandidate,
|
||||
candidate: &ImportOperationCandidate,
|
||||
) {
|
||||
let findings = candidate
|
||||
.findings
|
||||
.iter()
|
||||
.map(tool_quality_finding_from_import)
|
||||
.collect::<Vec<_>>();
|
||||
if findings.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut wizard_state = draft.wizard_state.take().unwrap_or_default();
|
||||
wizard_state.import_findings = findings;
|
||||
draft.wizard_state = Some(wizard_state);
|
||||
}
|
||||
|
||||
fn tool_quality_finding_from_import(finding: &ImportFinding) -> ToolQualityFinding {
|
||||
ToolQualityFinding {
|
||||
severity: match finding.severity {
|
||||
ImportFindingSeverity::Info => ToolQualitySeverity::Info,
|
||||
ImportFindingSeverity::Warning => ToolQualitySeverity::Warning,
|
||||
ImportFindingSeverity::Error => ToolQualitySeverity::Error,
|
||||
},
|
||||
code: format!("openapi_import.{}", finding.code),
|
||||
message: finding.message.clone(),
|
||||
suggested_action: Some(
|
||||
"Откройте черновик в мастере и уточните описание, схемы или маппинг перед публикацией."
|
||||
.to_owned(),
|
||||
),
|
||||
field_path: finding.operation_key.clone(),
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod integration {
|
||||
mod auth_rate_limit;
|
||||
mod common;
|
||||
mod community_access_usage;
|
||||
mod openapi_import;
|
||||
mod operations_agents;
|
||||
mod secrets_import_auth;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
use admin_api::service::{OpenApiImportCreatePayload, OpenApiImportPreviewPayload};
|
||||
use crank_core::WorkspaceId;
|
||||
use serial_test::serial;
|
||||
|
||||
use super::common::{
|
||||
test_auth_settings, test_registry, test_secret_crypto, test_service, test_storage_root,
|
||||
};
|
||||
|
||||
const OPENAPI3: &str = r#"
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Frankfurter API
|
||||
servers:
|
||||
- url: https://api.frankfurter.dev
|
||||
paths:
|
||||
/v2/latest:
|
||||
get:
|
||||
operationId: latestRates
|
||||
summary: Получить последние курсы валют
|
||||
description: Курсы.
|
||||
tags: [currency]
|
||||
parameters:
|
||||
- name: base
|
||||
in: query
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
base: { type: string }
|
||||
rates:
|
||||
type: object
|
||||
"#;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn previews_openapi_and_creates_draft_operations() {
|
||||
let registry = test_registry().await;
|
||||
let service = test_service(
|
||||
registry,
|
||||
test_storage_root("openapi_import"),
|
||||
test_auth_settings(),
|
||||
test_secret_crypto(),
|
||||
);
|
||||
let workspace_id = WorkspaceId::new("ws_default");
|
||||
|
||||
let preview = service
|
||||
.preview_openapi_import(
|
||||
&workspace_id,
|
||||
OpenApiImportPreviewPayload {
|
||||
document: OPENAPI3.to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(preview.preview.groups.len(), 1);
|
||||
assert_eq!(
|
||||
preview.preview.groups[0].operations[0].suggested_name,
|
||||
"latest_rates"
|
||||
);
|
||||
|
||||
let created = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&preview.job_id.as_str().into(),
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
conflict_mode: "skip".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(created.created.len(), 1);
|
||||
assert_eq!(created.created[0].name, "latest_rates");
|
||||
assert!(created.skipped.is_empty());
|
||||
|
||||
let operations = service.list_operations(&workspace_id).await.unwrap();
|
||||
assert!(
|
||||
operations
|
||||
.iter()
|
||||
.any(|operation| operation.name == "latest_rates")
|
||||
);
|
||||
let imported = operations
|
||||
.iter()
|
||||
.find(|operation| operation.name == "latest_rates")
|
||||
.unwrap();
|
||||
let version = service
|
||||
.get_operation_version(
|
||||
&workspace_id,
|
||||
&imported.id.as_str().into(),
|
||||
imported.current_draft_version,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let import_findings = &version
|
||||
.snapshot
|
||||
.wizard_state
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.import_findings;
|
||||
assert!(
|
||||
import_findings
|
||||
.iter()
|
||||
.any(|finding| finding.code == "openapi_import.weak_tool_description")
|
||||
);
|
||||
|
||||
let skipped = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&preview.job_id.as_str().into(),
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
conflict_mode: "skip".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(skipped.created.is_empty());
|
||||
assert_eq!(skipped.skipped.len(), 1);
|
||||
assert_eq!(skipped.skipped[0].name, "latest_rates");
|
||||
assert_eq!(skipped.findings[0].code, "operation_name_conflict");
|
||||
|
||||
let renamed = service
|
||||
.create_openapi_import(
|
||||
&workspace_id,
|
||||
&preview.job_id.as_str().into(),
|
||||
OpenApiImportCreatePayload {
|
||||
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||
conflict_mode: "rename".to_owned(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(renamed.created.len(), 1);
|
||||
assert_eq!(renamed.created[0].name, "latest_rates_2");
|
||||
assert_eq!(renamed.findings[0].code, "operation_name_renamed");
|
||||
}
|
||||
Vendored
+449
@@ -0,0 +1,449 @@
|
||||
.page-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.openapi-import-modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.openapi-import-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.openapi-import-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.52);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.openapi-import-dialog {
|
||||
position: relative;
|
||||
width: min(1040px, calc(100vw - 32px));
|
||||
max-height: min(860px, calc(100vh - 32px));
|
||||
margin: 16px auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 22px;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.28);
|
||||
}
|
||||
|
||||
.openapi-import-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 22px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.openapi-import-header h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.openapi-import-header p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.openapi-import-body {
|
||||
overflow: auto;
|
||||
padding: 20px 24px 24px;
|
||||
}
|
||||
|
||||
.openapi-import-upload {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.openapi-file-label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#openapi-import-document {
|
||||
min-height: 180px;
|
||||
resize: vertical;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-primary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.openapi-import-actions,
|
||||
.openapi-import-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.openapi-import-status {
|
||||
margin-top: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.openapi-import-status.error {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.openapi-import-preview {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.openapi-import-source,
|
||||
.openapi-import-group {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.openapi-import-source {
|
||||
padding: 14px 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.openapi-import-server-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.openapi-import-server-row label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#openapi-import-server,
|
||||
#openapi-import-conflict-mode,
|
||||
#openapi-import-search,
|
||||
#openapi-import-method-filter,
|
||||
.openapi-import-server-custom {
|
||||
max-width: 520px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.openapi-import-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(160px, 220px) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.openapi-import-filter {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.openapi-import-filter label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
#openapi-import-search,
|
||||
#openapi-import-method-filter {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.openapi-import-bulk-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.openapi-import-groups {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.openapi-import-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.openapi-import-group-count {
|
||||
margin-left: auto;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.openapi-import-group-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.openapi-import-operation {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.openapi-import-operation:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.openapi-import-operation-title {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.openapi-import-operation-meta {
|
||||
margin-top: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.openapi-import-method {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.openapi-import-findings {
|
||||
grid-column: 2 / -1;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.openapi-import-finding {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
color: var(--amber);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.openapi-import-finding-info {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.openapi-import-finding-error {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.openapi-import-finding strong {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.openapi-import-mapping-preview {
|
||||
grid-column: 2 / -1;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.openapi-import-mapping-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.openapi-import-mapping-label {
|
||||
min-width: 52px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.openapi-import-mapping-chip {
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-primary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.openapi-import-mapping-more {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.openapi-import-document-findings,
|
||||
.openapi-import-result {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.openapi-import-result strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.openapi-import-primary-result {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.openapi-import-primary-result .btn-primary {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.openapi-import-result-table {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.openapi-import-result-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 0.9fr) minmax(260px, 1.5fr) auto;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.openapi-import-result-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.openapi-import-result-head {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.openapi-import-result-name {
|
||||
color: var(--text-primary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.openapi-import-result-meta {
|
||||
margin-top: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.openapi-import-result-findings {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.openapi-import-result-ok {
|
||||
color: var(--green);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.openapi-import-result-more {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.openapi-import-result-action {
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.openapi-import-result-skipped {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.openapi-import-created-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 4px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.openapi-import-created-list a {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.openapi-import-toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.openapi-import-bulk-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.openapi-import-operation {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.openapi-import-result-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.openapi-import-method {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,137 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mapping-builder-card .config-card-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.mapping-table {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mapping-row {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(130px, 1fr)
|
||||
minmax(105px, 0.65fr)
|
||||
minmax(130px, 1fr)
|
||||
minmax(120px, 0.8fr)
|
||||
minmax(150px, 0.9fr)
|
||||
34px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
.response-mapping-row {
|
||||
grid-template-columns: minmax(160px, 1fr) minmax(140px, 1fr) 34px;
|
||||
}
|
||||
|
||||
.mapping-row-remove {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mapping-builder-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.advanced-mapping-details {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.advanced-mapping-details summary {
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.advanced-mapping-details[open] summary {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.mapping-response-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.85fr) minmax(320px, 1.15fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.json-tree-picker {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
|
||||
.json-tree-node {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 7px 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.json-tree-node:hover {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mapping-warnings {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mapping-warning {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(245, 158, 11, 0.34);
|
||||
border-radius: 10px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.mapping-warning strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.mapping-row,
|
||||
.response-mapping-row,
|
||||
.mapping-response-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mapping-row-remove {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════
|
||||
STEP SIDEBAR
|
||||
══════════════════════════════════════════════════ */
|
||||
@@ -1527,6 +1658,16 @@
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.quality-finding-jump {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.quality-focus-target {
|
||||
outline: 2px solid rgba(13, 148, 136, 0.85);
|
||||
box-shadow: 0 0 0 4px rgba(13, 148, 136, 0.14);
|
||||
transition: box-shadow 0.2s ease, outline-color 0.2s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.agent-preview-summary {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -13,20 +13,35 @@
|
||||
<div class="section-divider-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-body" style="gap: 0; padding: 0;">
|
||||
<div class="code-block" style="border-radius: 0; border: none;">
|
||||
<div class="code-toolbar">
|
||||
<div class="code-dots"><span></span><span></span><span></span></div>
|
||||
<span class="code-toolbar-label">yaml / request-fields</span>
|
||||
</div>
|
||||
<textarea id="tool-input-mapping" class="form-textarea code-textarea" rows="12">first_name: "$.input.first_name"
|
||||
<div class="config-card mapping-builder-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div>
|
||||
<div class="config-card-title">Конструктор API-запроса</div>
|
||||
<div class="config-card-subtitle">Укажите, куда отправлять поля инструмента: в путь, query-параметры, заголовки или JSON-тело.</div>
|
||||
</div>
|
||||
<button id="wizard-add-request-mapping-row" class="btn-ghost-sm" type="button">Добавить поле</button>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 14px;">
|
||||
<div class="mapping-table" id="wizard-request-mapping-rows" data-testid="wizard-request-mapping-rows"></div>
|
||||
<div class="mapping-builder-actions">
|
||||
<button id="wizard-auto-request-mapping" class="btn-ghost-sm" type="button">Собрать из входного JSON</button>
|
||||
<button id="wizard-sync-request-mapping" class="btn-ghost-sm" type="button">Обновить YAML</button>
|
||||
</div>
|
||||
<details class="advanced-mapping-details">
|
||||
<summary>Дополнительно: YAML маппинга запроса</summary>
|
||||
<div class="code-block">
|
||||
<div class="code-toolbar">
|
||||
<div class="code-dots"><span></span><span></span><span></span></div>
|
||||
<span class="code-toolbar-label">yaml / request-fields</span>
|
||||
</div>
|
||||
<textarea id="tool-input-mapping" class="form-textarea code-textarea" rows="10">first_name: "$.input.first_name"
|
||||
last_name: "$.input.last_name"
|
||||
email: "$.input.email"
|
||||
company: "$.input.company"
|
||||
phone: "$.input.phone"
|
||||
source: "$.input.source"</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,18 +50,42 @@ source: "$.input.source"</textarea>
|
||||
<div class="section-divider-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="config-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-body" style="gap: 0; padding: 0;">
|
||||
<div class="code-block" style="border-radius: 0; border: none;">
|
||||
<div class="code-toolbar">
|
||||
<div class="code-dots"><span></span><span></span><span></span></div>
|
||||
<span class="code-toolbar-label">yaml / response-fields</span>
|
||||
<div class="config-card mapping-builder-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div>
|
||||
<div class="config-card-title">Конструктор ответа инструмента</div>
|
||||
<div class="config-card-subtitle">Выберите поля из ответа API, которые нужно вернуть MCP клиенту.</div>
|
||||
</div>
|
||||
<button id="wizard-add-response-mapping-row" class="btn-ghost-sm" type="button">Добавить поле</button>
|
||||
</div>
|
||||
<div class="config-card-body" style="gap: 14px;">
|
||||
<div class="mapping-response-layout">
|
||||
<div>
|
||||
<div class="form-label">Поля ответа API</div>
|
||||
<div id="wizard-response-json-tree" class="json-tree-picker" data-testid="wizard-response-json-tree"></div>
|
||||
</div>
|
||||
<textarea id="tool-output-mapping" class="form-textarea code-textarea" rows="10">lead_id: "$.response.data.id"
|
||||
<div>
|
||||
<div class="form-label">Поля результата инструмента</div>
|
||||
<div class="mapping-table" id="wizard-response-mapping-rows" data-testid="wizard-response-mapping-rows"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mapping-builder-actions">
|
||||
<button id="wizard-auto-response-mapping" class="btn-ghost-sm" type="button">Собрать из ответа JSON</button>
|
||||
<button id="wizard-sync-response-mapping" class="btn-ghost-sm" type="button">Обновить YAML</button>
|
||||
</div>
|
||||
<details class="advanced-mapping-details">
|
||||
<summary>Дополнительно: YAML маппинга ответа</summary>
|
||||
<div class="code-block">
|
||||
<div class="code-toolbar">
|
||||
<div class="code-dots"><span></span><span></span><span></span></div>
|
||||
<span class="code-toolbar-label">yaml / response-fields</span>
|
||||
</div>
|
||||
<textarea id="tool-output-mapping" class="form-textarea code-textarea" rows="10">lead_id: "$.response.data.id"
|
||||
status: "$.response.data.status"
|
||||
created_at: "$.response.data.created_at"
|
||||
owner_id: "$.response.data.owner.id"</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -103,6 +142,8 @@ tls:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="wizard-mapping-warnings" class="mapping-warnings" hidden data-testid="wizard-mapping-warnings"></div>
|
||||
|
||||
<div class="config-card" id="agent-facing-preview-card" style="margin-bottom: 20px;">
|
||||
<div class="config-card-header">
|
||||
<div class="config-card-header-icon">
|
||||
@@ -196,6 +237,7 @@ tls:
|
||||
"last_name": "Lovelace",
|
||||
"email": "ada@example.com"
|
||||
}</textarea>
|
||||
<input id="wizard-input-sample-file" type="file" accept=".json,application/json" hidden>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" data-i18n="wizard.step5.output_sample">Пример ответа</label>
|
||||
@@ -203,9 +245,13 @@ tls:
|
||||
"id": "lead_123",
|
||||
"status": "created"
|
||||
}</textarea>
|
||||
<input id="wizard-output-sample-file" type="file" accept=".json,application/json" hidden>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap: 8px; flex-wrap: wrap;">
|
||||
<button id="wizard-load-input-sample-file" class="btn-ghost-sm" type="button">Загрузить JSON запроса</button>
|
||||
<button id="wizard-load-output-sample-file" class="btn-ghost-sm" type="button">Загрузить JSON ответа</button>
|
||||
<button id="wizard-format-samples" class="btn-ghost-sm" type="button">Проверить и форматировать JSON</button>
|
||||
<button id="wizard-upload-input-sample" class="btn-primary-sm" type="button" data-i18n="wizard.step5.save_input_sample">Сохранить входной пример</button>
|
||||
<button id="wizard-upload-output-sample" class="btn-ghost-sm" type="button" data-i18n="wizard.step5.save_output_sample">Сохранить выходной пример</button>
|
||||
<button id="wizard-generate-draft" class="btn-ghost-sm" type="button" data-i18n="wizard.step5.generate_draft">Пересобрать по примерам</button>
|
||||
|
||||
+80
-4
@@ -9,6 +9,7 @@
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/catalog.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<link rel="stylesheet" href="css/openapi-import.css">
|
||||
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
|
||||
</head>
|
||||
<body x-data="catalog()">
|
||||
@@ -88,10 +89,16 @@
|
||||
<h1 class="page-heading" data-i18n="ops.title">Operations</h1>
|
||||
<p class="page-subheading" data-i18n="ops.subtitle">Catalog of tools. List of created MCP tools for API endpoints.</p>
|
||||
</div>
|
||||
<button class="btn-new" @click="handleNewOperation()">
|
||||
<svg width="13" height="13"><use href="icons/general/plus.svg#icon"/></svg>
|
||||
<span data-i18n="ops.new">New operation</span>
|
||||
</button>
|
||||
<div class="page-header-actions">
|
||||
<button class="btn-secondary openapi-import-trigger" @click="handleOpenApiImport()">
|
||||
<svg width="13" height="13"><use href="icons/wizard/upload.svg#icon"/></svg>
|
||||
<span>Импорт OpenAPI</span>
|
||||
</button>
|
||||
<button class="btn-new" @click="handleNewOperation()">
|
||||
<svg width="13" height="13"><use href="icons/general/plus.svg#icon"/></svg>
|
||||
<span data-i18n="ops.new">New operation</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
@@ -367,6 +374,75 @@
|
||||
|
||||
</div><!-- /page -->
|
||||
|
||||
<div class="openapi-import-modal" id="openapi-import-modal" hidden>
|
||||
<div class="openapi-import-backdrop" data-openapi-close></div>
|
||||
<section class="openapi-import-dialog" role="dialog" aria-modal="true" aria-labelledby="openapi-import-title">
|
||||
<div class="openapi-import-header">
|
||||
<div>
|
||||
<h2 id="openapi-import-title">Импорт OpenAPI</h2>
|
||||
<p>Загрузите OpenAPI/Swagger документ, выберите методы и создайте черновики MCP-инструментов.</p>
|
||||
</div>
|
||||
<button class="modal-close" type="button" data-openapi-close aria-label="Закрыть">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
|
||||
<line x1="1" y1="1" x2="11" y2="11"></line><line x1="11" y1="1" x2="1" y2="11"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="openapi-import-body">
|
||||
<div class="openapi-import-upload">
|
||||
<label class="openapi-file-label">
|
||||
<span>Файл OpenAPI/Swagger</span>
|
||||
<input id="openapi-import-file" type="file" accept=".yaml,.yml,.json,application/json,text/yaml">
|
||||
</label>
|
||||
<textarea id="openapi-import-document" spellcheck="false" placeholder="Вставьте openapi.yaml или swagger.json"></textarea>
|
||||
<div class="openapi-import-actions">
|
||||
<button class="btn-primary" id="openapi-import-preview" type="button">Разобрать документ</button>
|
||||
<button class="btn-secondary" id="openapi-import-reset" type="button">Сбросить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="openapi-import-status" id="openapi-import-status"></div>
|
||||
<div class="openapi-import-preview" id="openapi-import-preview-panel" hidden>
|
||||
<div class="openapi-import-source" id="openapi-import-source"></div>
|
||||
<div class="openapi-import-server-row">
|
||||
<label for="openapi-import-server">Base URL</label>
|
||||
<select id="openapi-import-server"></select>
|
||||
<input id="openapi-import-server-custom" class="openapi-import-server-custom" type="url" placeholder="Или укажите свой base URL, например https://api.example.com">
|
||||
</div>
|
||||
<div class="openapi-import-server-row">
|
||||
<label for="openapi-import-conflict-mode">Если операция уже существует</label>
|
||||
<select id="openapi-import-conflict-mode">
|
||||
<option value="rename" selected>Создать копию с новым именем</option>
|
||||
<option value="skip">Пропустить</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="openapi-import-toolbar">
|
||||
<div class="openapi-import-filter">
|
||||
<label for="openapi-import-search">Поиск методов</label>
|
||||
<input id="openapi-import-search" type="search" placeholder="Название, operationId или путь">
|
||||
</div>
|
||||
<div class="openapi-import-filter">
|
||||
<label for="openapi-import-method-filter">HTTP-метод</label>
|
||||
<select id="openapi-import-method-filter">
|
||||
<option value="">Все методы</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="openapi-import-bulk-actions">
|
||||
<button class="btn-secondary" id="openapi-import-select-visible" type="button">Выбрать видимые</button>
|
||||
<button class="btn-secondary" id="openapi-import-clear-visible" type="button">Снять видимые</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="openapi-import-groups" id="openapi-import-groups"></div>
|
||||
<div class="openapi-import-footer">
|
||||
<span id="openapi-import-selection">Выбрано: 0</span>
|
||||
<button class="btn-primary" id="openapi-import-create" type="button">Создать черновики</button>
|
||||
</div>
|
||||
<div class="openapi-import-result" id="openapi-import-result" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="%CRANK_BUNDLE_OPERATIONS%"></script>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -244,6 +244,14 @@
|
||||
}
|
||||
);
|
||||
},
|
||||
previewOpenApiImport: function(workspaceId, documentText) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/preview', {
|
||||
document: documentText,
|
||||
});
|
||||
},
|
||||
createOpenApiImport: function(workspaceId, jobId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/' + encodeURIComponent(jobId) + '/create', payload);
|
||||
},
|
||||
listAgents: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents');
|
||||
},
|
||||
|
||||
@@ -506,6 +506,15 @@ document.addEventListener('alpine:init', function() {
|
||||
window.location.href = (window.CrankRoutes && window.CrankRoutes.wizard) || '/wizard/';
|
||||
},
|
||||
|
||||
handleOpenApiImport() {
|
||||
if (window.CrankOpenApiImport) {
|
||||
window.CrankOpenApiImport.open({
|
||||
workspaceId: this.workspaceId,
|
||||
onImported: this.reload.bind(this),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
handleLogout() {
|
||||
window.CrankAuth.logout();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
(function() {
|
||||
var state = {
|
||||
workspaceId: null,
|
||||
onImported: null,
|
||||
jobId: null,
|
||||
preview: null,
|
||||
filterQuery: '',
|
||||
filterMethod: '',
|
||||
};
|
||||
|
||||
function qs(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function setStatus(message, isError) {
|
||||
var node = qs('openapi-import-status');
|
||||
if (!node) return;
|
||||
node.textContent = message || '';
|
||||
node.classList.toggle('error', !!isError);
|
||||
}
|
||||
|
||||
function selectedKeys() {
|
||||
return Array.from(document.querySelectorAll('[data-openapi-operation]:checked'))
|
||||
.map(function(input) { return input.value; });
|
||||
}
|
||||
|
||||
function operationRows() {
|
||||
return Array.from(document.querySelectorAll('.openapi-import-operation'));
|
||||
}
|
||||
|
||||
function visibleOperationRows() {
|
||||
return operationRows().filter(function(row) { return !row.hidden; });
|
||||
}
|
||||
|
||||
function updateSelection() {
|
||||
var node = qs('openapi-import-selection');
|
||||
var selectedCount = selectedKeys().length;
|
||||
var totalCount = document.querySelectorAll('[data-openapi-operation]').length;
|
||||
var visibleCount = visibleOperationRows().length;
|
||||
if (node) node.textContent = 'Выбрано: ' + selectedCount + ' из ' + totalCount + ', показано: ' + visibleCount;
|
||||
updateGroupSelectionState();
|
||||
}
|
||||
|
||||
function updateGroupSelectionState() {
|
||||
document.querySelectorAll('.openapi-import-group').forEach(function(groupNode) {
|
||||
var rows = Array.from(groupNode.querySelectorAll('.openapi-import-operation'));
|
||||
var visibleRows = rows.filter(function(row) { return !row.hidden; });
|
||||
var visibleInputs = visibleRows
|
||||
.map(function(row) { return row.querySelector('[data-openapi-operation]'); })
|
||||
.filter(Boolean);
|
||||
var selectedVisible = visibleInputs.filter(function(input) { return input.checked; }).length;
|
||||
var selectedTotal = rows
|
||||
.map(function(row) { return row.querySelector('[data-openapi-operation]'); })
|
||||
.filter(function(input) { return input && input.checked; }).length;
|
||||
var checkbox = groupNode.querySelector('[data-openapi-group]');
|
||||
if (checkbox) {
|
||||
checkbox.indeterminate = selectedVisible > 0 && selectedVisible < visibleInputs.length;
|
||||
checkbox.checked = visibleInputs.length > 0 && selectedVisible === visibleInputs.length;
|
||||
}
|
||||
var counter = groupNode.querySelector('[data-openapi-group-count]');
|
||||
if (counter) {
|
||||
counter.textContent = 'выбрано ' + selectedTotal + ' из ' + rows.length + ', показано ' + visibleRows.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
var query = state.filterQuery.toLowerCase();
|
||||
var method = state.filterMethod.toLowerCase();
|
||||
document.querySelectorAll('.openapi-import-group').forEach(function(groupNode) {
|
||||
var visibleInGroup = 0;
|
||||
groupNode.querySelectorAll('.openapi-import-operation').forEach(function(row) {
|
||||
var rowMethod = String(row.dataset.openapiMethod || '').toLowerCase();
|
||||
var searchText = String(row.dataset.openapiSearch || '').toLowerCase();
|
||||
var visible = (!method || rowMethod === method) && (!query || searchText.indexOf(query) >= 0);
|
||||
row.hidden = !visible;
|
||||
if (visible) visibleInGroup += 1;
|
||||
});
|
||||
groupNode.hidden = visibleInGroup === 0;
|
||||
});
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
function setVisibleSelection(checked) {
|
||||
visibleOperationRows().forEach(function(row) {
|
||||
var input = row.querySelector('[data-openapi-operation]');
|
||||
if (input) input.checked = checked;
|
||||
});
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
function renderPreview(response) {
|
||||
state.jobId = response.job_id;
|
||||
state.preview = response.preview;
|
||||
state.filterQuery = '';
|
||||
state.filterMethod = '';
|
||||
var preview = response.preview;
|
||||
qs('openapi-import-preview-panel').hidden = false;
|
||||
qs('openapi-import-result').hidden = true;
|
||||
qs('openapi-import-result').innerHTML = '';
|
||||
qs('openapi-import-source').textContent = [
|
||||
preview.source.title || 'Imported API',
|
||||
preview.source.format + (preview.source.version ? ' ' + preview.source.version : ''),
|
||||
'методов: ' + preview.groups.reduce(function(sum, group) { return sum + group.operations.length; }, 0),
|
||||
].join(' · ');
|
||||
|
||||
var serverSelect = qs('openapi-import-server');
|
||||
serverSelect.innerHTML = '';
|
||||
var servers = preview.source.servers && preview.source.servers.length ? preview.source.servers : [''];
|
||||
servers.forEach(function(server) {
|
||||
var option = document.createElement('option');
|
||||
option.value = server;
|
||||
option.textContent = server || 'Указать позже';
|
||||
serverSelect.appendChild(option);
|
||||
});
|
||||
|
||||
var searchInput = qs('openapi-import-search');
|
||||
var methodFilter = qs('openapi-import-method-filter');
|
||||
if (searchInput) searchInput.value = '';
|
||||
if (methodFilter) {
|
||||
methodFilter.innerHTML = '<option value="">Все методы</option>';
|
||||
Array.from(new Set(preview.groups.flatMap(function(group) {
|
||||
return group.operations.map(function(operation) { return operation.method; });
|
||||
}))).sort().forEach(function(method) {
|
||||
var option = document.createElement('option');
|
||||
option.value = method;
|
||||
option.textContent = method;
|
||||
methodFilter.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
var groupsNode = qs('openapi-import-groups');
|
||||
groupsNode.innerHTML = '';
|
||||
if (preview.findings && preview.findings.length) {
|
||||
var documentFindings = document.createElement('div');
|
||||
documentFindings.className = 'openapi-import-document-findings';
|
||||
documentFindings.innerHTML = preview.findings.map(function(finding) {
|
||||
return renderFinding(finding);
|
||||
}).join('');
|
||||
groupsNode.appendChild(documentFindings);
|
||||
}
|
||||
preview.groups.forEach(function(group) {
|
||||
var groupNode = document.createElement('section');
|
||||
groupNode.className = 'openapi-import-group';
|
||||
var header = document.createElement('div');
|
||||
header.className = 'openapi-import-group-header';
|
||||
header.innerHTML = '<span>' + escapeHtml(group.title) + ' · ' + group.operations.length + '</span>'
|
||||
+ '<span class="openapi-import-group-count" data-openapi-group-count></span>'
|
||||
+ '<label class="openapi-import-group-toggle"><input type="checkbox" data-openapi-group checked> Выбрать группу</label>';
|
||||
groupNode.appendChild(header);
|
||||
|
||||
group.operations.forEach(function(operation) {
|
||||
var row = document.createElement('label');
|
||||
row.className = 'openapi-import-operation';
|
||||
row.dataset.openapiMethod = operation.method;
|
||||
row.dataset.openapiSearch = [
|
||||
group.title,
|
||||
operation.key,
|
||||
operation.suggested_name,
|
||||
operation.suggested_display_name,
|
||||
operation.path,
|
||||
operation.method,
|
||||
].join(' ');
|
||||
row.innerHTML = [
|
||||
'<input type="checkbox" data-openapi-operation value="' + escapeHtml(operation.key) + '" checked>',
|
||||
'<div><div class="openapi-import-operation-title">' + escapeHtml(operation.suggested_display_name) + '</div>',
|
||||
'<div class="openapi-import-operation-meta"><code>' + escapeHtml(operation.suggested_name) + '</code> · ' + escapeHtml(operation.path) + ' · входов: ' + operation.input_fields + ' · выходов: ' + operation.output_fields + '</div></div>',
|
||||
'<span class="openapi-import-method">' + escapeHtml(operation.method) + '</span>',
|
||||
].join('');
|
||||
row.appendChild(renderOperationMappingPreview(operation));
|
||||
if (operation.findings && operation.findings.length) {
|
||||
var findings = document.createElement('div');
|
||||
findings.className = 'openapi-import-findings';
|
||||
findings.innerHTML = operation.findings.map(function(finding) {
|
||||
return renderFinding(finding);
|
||||
}).join('');
|
||||
row.appendChild(findings);
|
||||
}
|
||||
groupNode.appendChild(row);
|
||||
});
|
||||
var groupCheckbox = groupNode.querySelector('[data-openapi-group]');
|
||||
groupCheckbox.addEventListener('change', function() {
|
||||
groupNode.querySelectorAll('.openapi-import-operation').forEach(function(row) {
|
||||
if (row.hidden) return;
|
||||
var input = row.querySelector('[data-openapi-operation]');
|
||||
if (input) input.checked = groupCheckbox.checked;
|
||||
});
|
||||
updateSelection();
|
||||
});
|
||||
groupsNode.appendChild(groupNode);
|
||||
});
|
||||
groupsNode.querySelectorAll('[data-openapi-operation]').forEach(function(input) {
|
||||
input.addEventListener('change', updateSelection);
|
||||
});
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function renderOperationMappingPreview(operation) {
|
||||
var details = document.createElement('div');
|
||||
details.className = 'openapi-import-mapping-preview';
|
||||
|
||||
var request = summarizeRequestMapping(operation);
|
||||
[
|
||||
['Path', request.path],
|
||||
['Query', request.query],
|
||||
['Header', request.headers],
|
||||
['Body', request.body],
|
||||
].forEach(function(entry) {
|
||||
appendMappingGroup(details, entry[0], entry[1]);
|
||||
});
|
||||
appendMappingGroup(details, 'Ответ', summarizeResponseMapping(operation));
|
||||
return details;
|
||||
}
|
||||
|
||||
function summarizeRequestMapping(operation) {
|
||||
var result = {
|
||||
path: [],
|
||||
query: [],
|
||||
headers: [],
|
||||
body: [],
|
||||
};
|
||||
var rules = operation
|
||||
&& operation.draft
|
||||
&& operation.draft.input_mapping
|
||||
&& Array.isArray(operation.draft.input_mapping.rules)
|
||||
? operation.draft.input_mapping.rules
|
||||
: [];
|
||||
|
||||
rules.forEach(function(rule) {
|
||||
var target = String(rule.target || '');
|
||||
if (target.indexOf('$.request.path.') === 0) {
|
||||
result.path.push(target.replace('$.request.path.', ''));
|
||||
} else if (target.indexOf('$.request.query.') === 0) {
|
||||
result.query.push(target.replace('$.request.query.', ''));
|
||||
} else if (target.indexOf('$.request.headers.') === 0) {
|
||||
result.headers.push(target.replace('$.request.headers.', ''));
|
||||
} else if (target.indexOf('$.request.body.') === 0) {
|
||||
result.body.push(target.replace('$.request.body.', ''));
|
||||
} else if (target === '$.request.body') {
|
||||
result.body.push('body');
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function summarizeResponseMapping(operation) {
|
||||
var rules = operation
|
||||
&& operation.draft
|
||||
&& operation.draft.output_mapping
|
||||
&& Array.isArray(operation.draft.output_mapping.rules)
|
||||
? operation.draft.output_mapping.rules
|
||||
: [];
|
||||
|
||||
return rules.map(function(rule) {
|
||||
var target = String(rule.target || '').replace('$.output.', '').replace('$.output', 'result');
|
||||
var source = String(rule.source || '').replace('$.response.body.', '').replace('$.response.body', 'body');
|
||||
return target + ' ← ' + source;
|
||||
});
|
||||
}
|
||||
|
||||
function appendMappingGroup(root, label, values) {
|
||||
if (!values || !values.length) return;
|
||||
var group = document.createElement('div');
|
||||
group.className = 'openapi-import-mapping-group';
|
||||
|
||||
var title = document.createElement('span');
|
||||
title.className = 'openapi-import-mapping-label';
|
||||
title.textContent = label;
|
||||
group.appendChild(title);
|
||||
|
||||
values.slice(0, 8).forEach(function(value) {
|
||||
var chip = document.createElement('code');
|
||||
chip.className = 'openapi-import-mapping-chip';
|
||||
chip.textContent = value;
|
||||
group.appendChild(chip);
|
||||
});
|
||||
if (values.length > 8) {
|
||||
var more = document.createElement('span');
|
||||
more.className = 'openapi-import-mapping-more';
|
||||
more.textContent = '+' + (values.length - 8);
|
||||
group.appendChild(more);
|
||||
}
|
||||
root.appendChild(group);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function renderFinding(finding) {
|
||||
var severity = String(finding && finding.severity || 'warning').toLowerCase();
|
||||
var label = severity === 'info' ? 'i' : severity === 'error' ? '×' : '!';
|
||||
return '<span class="openapi-import-finding openapi-import-finding-' + escapeHtml(severity) + '">'
|
||||
+ '<strong>' + label + '</strong> '
|
||||
+ escapeHtml(finding.message)
|
||||
+ '</span>';
|
||||
}
|
||||
|
||||
function previewOperationByName(name) {
|
||||
if (!state.preview || !state.preview.groups) return null;
|
||||
for (var groupIndex = 0; groupIndex < state.preview.groups.length; groupIndex += 1) {
|
||||
var operations = state.preview.groups[groupIndex].operations || [];
|
||||
for (var operationIndex = 0; operationIndex < operations.length; operationIndex += 1) {
|
||||
if (operations[operationIndex].suggested_name === name) {
|
||||
return operations[operationIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function wizardHref(operationId) {
|
||||
return ((window.CrankRoutes && window.CrankRoutes.wizard) || '/wizard/')
|
||||
+ '?mode=edit&operationId=' + encodeURIComponent(operationId);
|
||||
}
|
||||
|
||||
function appendFindingNodes(root, findings) {
|
||||
if (!findings || !findings.length) {
|
||||
var empty = document.createElement('span');
|
||||
empty.className = 'openapi-import-result-ok';
|
||||
empty.textContent = 'Критичных замечаний нет';
|
||||
root.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
findings.slice(0, 4).forEach(function(finding) {
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = renderFinding(finding);
|
||||
root.appendChild(wrapper.firstElementChild);
|
||||
});
|
||||
if (findings.length > 4) {
|
||||
var more = document.createElement('span');
|
||||
more.className = 'openapi-import-result-more';
|
||||
more.textContent = '+' + (findings.length - 4) + ' еще';
|
||||
root.appendChild(more);
|
||||
}
|
||||
}
|
||||
|
||||
async function preview() {
|
||||
if (!state.workspaceId) {
|
||||
setStatus('Не выбран workspace.', true);
|
||||
return;
|
||||
}
|
||||
var documentText = qs('openapi-import-document').value.trim();
|
||||
if (!documentText) {
|
||||
setStatus('Вставьте документ OpenAPI/Swagger или выберите файл.', true);
|
||||
return;
|
||||
}
|
||||
setStatus('Разбираю документ...');
|
||||
qs('openapi-import-preview').disabled = true;
|
||||
try {
|
||||
var response = await window.CrankApi.previewOpenApiImport(state.workspaceId, documentText);
|
||||
renderPreview(response);
|
||||
setStatus('Документ разобран. Выберите методы для импорта.');
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Не удалось разобрать документ.', true);
|
||||
} finally {
|
||||
qs('openapi-import-preview').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createDrafts() {
|
||||
var keys = selectedKeys();
|
||||
if (!keys.length) {
|
||||
setStatus('Выберите хотя бы один метод.', true);
|
||||
return;
|
||||
}
|
||||
if (!state.jobId) {
|
||||
setStatus('Сначала разберите OpenAPI документ.', true);
|
||||
return;
|
||||
}
|
||||
var serverUrl = (qs('openapi-import-server-custom').value || qs('openapi-import-server').value || '').trim();
|
||||
if (!serverUrl) {
|
||||
setStatus('Укажите base URL для создаваемых операций.', true);
|
||||
return;
|
||||
}
|
||||
setStatus('Создаю черновики...');
|
||||
qs('openapi-import-create').disabled = true;
|
||||
try {
|
||||
var response = await window.CrankApi.createOpenApiImport(state.workspaceId, state.jobId, {
|
||||
selected_operation_keys: keys,
|
||||
server_url: serverUrl,
|
||||
conflict_mode: qs('openapi-import-conflict-mode').value || 'rename',
|
||||
});
|
||||
var message = 'Создано: ' + response.created.length;
|
||||
if (response.skipped.length) message += ', пропущено: ' + response.skipped.length;
|
||||
setStatus(message);
|
||||
renderResult(response);
|
||||
if (typeof state.onImported === 'function') {
|
||||
await state.onImported();
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus(error.message || 'Не удалось создать черновики.', true);
|
||||
} finally {
|
||||
qs('openapi-import-create').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderResult(response) {
|
||||
var node = qs('openapi-import-result');
|
||||
if (!node) return;
|
||||
node.replaceChildren();
|
||||
|
||||
var title = document.createElement('strong');
|
||||
title.textContent = 'Результат импорта';
|
||||
node.appendChild(title);
|
||||
|
||||
if (response.created && response.created.length) {
|
||||
var firstHref = wizardHref(response.created[0].operation_id);
|
||||
var primary = document.createElement('div');
|
||||
primary.className = 'openapi-import-primary-result';
|
||||
primary.innerHTML = '<a class="btn-primary" href="' + firstHref + '">Открыть первый черновик в мастере</a>';
|
||||
node.appendChild(primary);
|
||||
|
||||
var table = document.createElement('div');
|
||||
table.className = 'openapi-import-result-table';
|
||||
table.setAttribute('role', 'table');
|
||||
table.innerHTML = [
|
||||
'<div class="openapi-import-result-row openapi-import-result-head" role="row">',
|
||||
'<div role="columnheader">Черновик</div>',
|
||||
'<div role="columnheader">Замечания</div>',
|
||||
'<div role="columnheader">Действие</div>',
|
||||
'</div>',
|
||||
].join('');
|
||||
|
||||
response.created.forEach(function(operation) {
|
||||
var previewOperation = previewOperationByName(operation.name);
|
||||
var row = document.createElement('div');
|
||||
row.className = 'openapi-import-result-row';
|
||||
row.setAttribute('role', 'row');
|
||||
|
||||
var nameCell = document.createElement('div');
|
||||
nameCell.setAttribute('role', 'cell');
|
||||
var name = document.createElement('div');
|
||||
name.className = 'openapi-import-result-name';
|
||||
name.textContent = operation.name;
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'openapi-import-result-meta';
|
||||
meta.textContent = previewOperation
|
||||
? previewOperation.method + ' ' + previewOperation.path + ' · v' + operation.version
|
||||
: 'v' + operation.version;
|
||||
nameCell.appendChild(name);
|
||||
nameCell.appendChild(meta);
|
||||
|
||||
var findingsCell = document.createElement('div');
|
||||
findingsCell.className = 'openapi-import-result-findings';
|
||||
findingsCell.setAttribute('role', 'cell');
|
||||
appendFindingNodes(findingsCell, previewOperation ? previewOperation.findings : []);
|
||||
|
||||
var actionCell = document.createElement('div');
|
||||
actionCell.setAttribute('role', 'cell');
|
||||
var action = document.createElement('a');
|
||||
action.className = 'btn-secondary openapi-import-result-action';
|
||||
action.href = wizardHref(operation.operation_id);
|
||||
action.textContent = previewOperation && previewOperation.findings && previewOperation.findings.length
|
||||
? 'Исправить в мастере'
|
||||
: 'Открыть в мастере';
|
||||
actionCell.appendChild(action);
|
||||
|
||||
row.appendChild(nameCell);
|
||||
row.appendChild(findingsCell);
|
||||
row.appendChild(actionCell);
|
||||
table.appendChild(row);
|
||||
});
|
||||
node.appendChild(table);
|
||||
}
|
||||
if (response.skipped && response.skipped.length) {
|
||||
var skipped = document.createElement('div');
|
||||
skipped.className = 'openapi-import-result-skipped';
|
||||
skipped.textContent = 'Пропущено: ' + response.skipped.map(function(item) {
|
||||
return (item.name || item.operation_key) + ' — ' + item.reason;
|
||||
}).join('; ');
|
||||
node.appendChild(skipped);
|
||||
}
|
||||
if (response.findings && response.findings.length) {
|
||||
var responseFindings = document.createElement('div');
|
||||
responseFindings.className = 'openapi-import-result-findings';
|
||||
response.findings.forEach(function(finding) {
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = renderFinding(finding);
|
||||
responseFindings.appendChild(wrapper.firstElementChild);
|
||||
});
|
||||
node.appendChild(responseFindings);
|
||||
}
|
||||
node.hidden = false;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
state.jobId = null;
|
||||
state.preview = null;
|
||||
state.filterQuery = '';
|
||||
state.filterMethod = '';
|
||||
qs('openapi-import-document').value = '';
|
||||
qs('openapi-import-file').value = '';
|
||||
qs('openapi-import-server-custom').value = '';
|
||||
qs('openapi-import-conflict-mode').value = 'rename';
|
||||
if (qs('openapi-import-search')) qs('openapi-import-search').value = '';
|
||||
if (qs('openapi-import-method-filter')) qs('openapi-import-method-filter').innerHTML = '<option value="">Все методы</option>';
|
||||
qs('openapi-import-preview-panel').hidden = true;
|
||||
qs('openapi-import-groups').innerHTML = '';
|
||||
qs('openapi-import-result').hidden = true;
|
||||
qs('openapi-import-result').innerHTML = '';
|
||||
setStatus('');
|
||||
updateSelection();
|
||||
}
|
||||
|
||||
function open(options) {
|
||||
state.workspaceId = options.workspaceId;
|
||||
state.onImported = options.onImported;
|
||||
qs('openapi-import-modal').hidden = false;
|
||||
}
|
||||
|
||||
function close() {
|
||||
qs('openapi-import-modal').hidden = true;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (!qs('openapi-import-modal')) return;
|
||||
qs('openapi-import-preview').addEventListener('click', preview);
|
||||
qs('openapi-import-create').addEventListener('click', createDrafts);
|
||||
qs('openapi-import-reset').addEventListener('click', reset);
|
||||
qs('openapi-import-search').addEventListener('input', function(event) {
|
||||
state.filterQuery = event.target.value.trim();
|
||||
applyFilters();
|
||||
});
|
||||
qs('openapi-import-method-filter').addEventListener('change', function(event) {
|
||||
state.filterMethod = event.target.value;
|
||||
applyFilters();
|
||||
});
|
||||
qs('openapi-import-select-visible').addEventListener('click', function() {
|
||||
setVisibleSelection(true);
|
||||
});
|
||||
qs('openapi-import-clear-visible').addEventListener('click', function() {
|
||||
setVisibleSelection(false);
|
||||
});
|
||||
qs('openapi-import-file').addEventListener('change', async function(event) {
|
||||
var file = event.target.files && event.target.files[0];
|
||||
if (!file) return;
|
||||
qs('openapi-import-document').value = await file.text();
|
||||
});
|
||||
document.querySelectorAll('[data-openapi-close]').forEach(function(node) {
|
||||
node.addEventListener('click', close);
|
||||
});
|
||||
});
|
||||
|
||||
window.CrankOpenApiImport = {
|
||||
open: open,
|
||||
close: close,
|
||||
reset: reset,
|
||||
};
|
||||
}());
|
||||
@@ -13,10 +13,17 @@ function buildToolDescription() {
|
||||
}
|
||||
|
||||
function buildWizardState() {
|
||||
var snapshot = wizardCurrentVersion && wizardCurrentVersion.snapshot
|
||||
? wizardCurrentVersion.snapshot
|
||||
: wizardCurrentVersion;
|
||||
var existingState = snapshot && snapshot.wizard_state ? snapshot.wizard_state : {};
|
||||
return {
|
||||
input_sample: parseStructuredText(textValue('wizard-input-sample')),
|
||||
output_sample: parseStructuredText(textValue('wizard-output-sample')),
|
||||
test_input: parseStructuredText(textValue('wizard-test-input')),
|
||||
import_findings: Array.isArray(existingState.import_findings)
|
||||
? existingState.import_findings.slice()
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,6 +31,10 @@ function collectWizardPayload() {
|
||||
var name = textValue('tool-name');
|
||||
if (!name) throw new Error(tKey('wizard.error.tool_name'));
|
||||
|
||||
if (window.CrankWizardMapping && typeof window.CrankWizardMapping.sync === 'function') {
|
||||
window.CrankWizardMapping.sync();
|
||||
}
|
||||
|
||||
var inputSchemaValue = parseStructuredText(textValue('tool-input-schema'));
|
||||
var outputSchemaValue = parseStructuredText(textValue('tool-output-schema'));
|
||||
var inputMappingValue = parseStructuredText(textValue('tool-input-mapping'));
|
||||
@@ -69,6 +80,7 @@ async function loadOperationForEdit() {
|
||||
wizardCurrentVersion = draftVersion;
|
||||
prefillWizardFromEdit(detail, draftVersion);
|
||||
renderAgentFacingPreview();
|
||||
renderImportQualityFindings(draftVersion);
|
||||
}
|
||||
|
||||
function setEditModePresentation() {
|
||||
@@ -111,6 +123,9 @@ function bindWizardLiveActions() {
|
||||
|
||||
var yamlInput = document.getElementById('wizard-import-yaml-file');
|
||||
if (yamlInput) yamlInput.addEventListener('change', handleImportYamlFileSelection);
|
||||
if (window.CrankWizardMapping && typeof window.CrankWizardMapping.initialize === 'function') {
|
||||
window.CrankWizardMapping.initialize();
|
||||
}
|
||||
bindAgentFacingPreview();
|
||||
}
|
||||
|
||||
@@ -421,6 +436,50 @@ function severityLabel(severity) {
|
||||
return tKey('wizard.quality.severity_info');
|
||||
}
|
||||
|
||||
function qualityTargetForFinding(finding) {
|
||||
var code = String(finding && finding.code || '');
|
||||
var path = String(finding && finding.field_path || '');
|
||||
|
||||
if (code.indexOf('tool_name') >= 0 || code.indexOf('weak_tool_name') >= 0) {
|
||||
return { step: 4, selector: '#tool-name', label: 'Перейти к имени инструмента' };
|
||||
}
|
||||
if (code.indexOf('description') >= 0 || path.indexOf('tool_description') >= 0) {
|
||||
return { step: 4, selector: '#tool-description', label: 'Перейти к описанию' };
|
||||
}
|
||||
if (code.indexOf('input') >= 0 || code.indexOf('parameter') >= 0 || path.indexOf('input_schema') >= 0) {
|
||||
return { step: 4, selector: '#tool-input-schema', label: 'Перейти к входной схеме' };
|
||||
}
|
||||
if (code.indexOf('output') >= 0 || code.indexOf('response_schema') >= 0 || path.indexOf('output_schema') >= 0) {
|
||||
return { step: 4, selector: '#tool-output-schema', label: 'Перейти к схеме ответа' };
|
||||
}
|
||||
if (code.indexOf('response_projection') >= 0 || code.indexOf('mapping') >= 0 || path.indexOf('output_mapping') >= 0) {
|
||||
return { step: 5, selector: '#tool-output-mapping', label: 'Перейти к маппингу ответа' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function focusWizardQualityTarget(target) {
|
||||
if (!target || !window.CrankWizardShell) return;
|
||||
var load = typeof window.CrankWizardShell.loadWizardPanels === 'function'
|
||||
? window.CrankWizardShell.loadWizardPanels([target.step])
|
||||
: Promise.resolve();
|
||||
load.then(function() {
|
||||
if (typeof window.CrankWizardShell.doGoToStep === 'function') {
|
||||
window.CrankWizardShell.doGoToStep(target.step);
|
||||
}
|
||||
window.setTimeout(function() {
|
||||
var element = document.querySelector(target.selector);
|
||||
if (!element) return;
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
if (typeof element.focus === 'function') element.focus();
|
||||
element.classList.add('quality-focus-target');
|
||||
window.setTimeout(function() {
|
||||
element.classList.remove('quality-focus-target');
|
||||
}, 1600);
|
||||
}, 80);
|
||||
});
|
||||
}
|
||||
|
||||
function renderQualityFindings(report) {
|
||||
var empty = document.getElementById('wizard-quality-empty');
|
||||
var list = document.getElementById('wizard-quality-findings');
|
||||
@@ -454,6 +513,19 @@ function renderQualityFindings(report) {
|
||||
item.appendChild(action);
|
||||
}
|
||||
|
||||
var target = qualityTargetForFinding(finding);
|
||||
if (target) {
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'btn-ghost-sm quality-finding-jump';
|
||||
button.textContent = target.label;
|
||||
button.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
focusWizardQualityTarget(target);
|
||||
});
|
||||
item.appendChild(button);
|
||||
}
|
||||
|
||||
if (finding.field_path) {
|
||||
var path = document.createElement('div');
|
||||
path.className = 'quality-finding-path';
|
||||
@@ -465,6 +537,26 @@ function renderQualityFindings(report) {
|
||||
});
|
||||
}
|
||||
|
||||
function renderImportQualityFindings(versionDocument) {
|
||||
var snapshot = versionDocument && versionDocument.snapshot
|
||||
? versionDocument.snapshot
|
||||
: versionDocument;
|
||||
var state = snapshot && snapshot.wizard_state ? snapshot.wizard_state : {};
|
||||
var findings = Array.isArray(state.import_findings) ? state.import_findings : [];
|
||||
if (!findings.length) return;
|
||||
|
||||
renderQualityFindings({
|
||||
blocking: findings.some(function(finding) { return finding.severity === 'error'; }),
|
||||
findings: findings,
|
||||
});
|
||||
|
||||
var empty = document.getElementById('wizard-quality-empty');
|
||||
if (empty) {
|
||||
empty.textContent = 'Рекомендации из OpenAPI import. Запустите проверку качества, чтобы пересчитать их по текущему черновику.';
|
||||
empty.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeWizardQuality() {
|
||||
if (!wizardWorkspaceId) {
|
||||
throw new Error(tKey('wizard.error.no_workspace'));
|
||||
@@ -574,6 +666,9 @@ async function generateDraftFromWizard() {
|
||||
setValue('tool-output-schema', JSON.stringify(crankSchemaToJsonSchema(generated.output_schema), null, 2));
|
||||
setValue('tool-input-mapping', mappingSetToEditorValue(generated.input_mapping, 'input', wizardProtocol));
|
||||
setValue('tool-output-mapping', mappingSetToEditorValue(generated.output_mapping, 'output', wizardProtocol));
|
||||
if (window.CrankWizardMapping && typeof window.CrankWizardMapping.renderFromEditors === 'function') {
|
||||
window.CrankWizardMapping.renderFromEditors();
|
||||
}
|
||||
renderAgentFacingPreview();
|
||||
showWizardLiveStatus(tKey('wizard.sample.generated'), tKey('wizard.sample.generated_body'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,725 @@
|
||||
(function() {
|
||||
var REQUEST_TARGETS = ['path', 'query', 'headers', 'body'];
|
||||
var SCALAR_TYPES = ['string', 'number', 'integer', 'boolean', 'null'];
|
||||
var suspendSync = false;
|
||||
|
||||
function field(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function readText(id) {
|
||||
var element = field(id);
|
||||
return element ? element.value.trim() : '';
|
||||
}
|
||||
|
||||
function writeText(id, value) {
|
||||
var element = field(id);
|
||||
if (element) element.value = value || '';
|
||||
}
|
||||
|
||||
function parseText(id) {
|
||||
if (typeof parseStructuredText === 'function') {
|
||||
return parseStructuredText(readText(id));
|
||||
}
|
||||
return JSON.parse(readText(id));
|
||||
}
|
||||
|
||||
function dumpYaml(object) {
|
||||
return window.jsyaml
|
||||
? window.jsyaml.dump(object, { lineWidth: -1, noRefs: true })
|
||||
: JSON.stringify(object, null, 2);
|
||||
}
|
||||
|
||||
function safeJson(id) {
|
||||
try {
|
||||
return parseText(id);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function scalarType(value) {
|
||||
if (value === null) return 'null';
|
||||
if (Array.isArray(value)) return 'array';
|
||||
var typeName = typeof value;
|
||||
if (typeName === 'number') return Number.isInteger(value) ? 'integer' : 'number';
|
||||
if (typeName === 'boolean') return 'boolean';
|
||||
if (typeName === 'object') return 'object';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function inferJsonSchema(value) {
|
||||
var typeName = scalarType(value);
|
||||
if (typeName === 'object') {
|
||||
var properties = {};
|
||||
var required = [];
|
||||
Object.keys(value || {}).forEach(function(name) {
|
||||
properties[name] = inferJsonSchema(value[name]);
|
||||
if (value[name] !== null && value[name] !== undefined) required.push(name);
|
||||
});
|
||||
var schema = {
|
||||
type: 'object',
|
||||
properties: properties,
|
||||
additionalProperties: false,
|
||||
};
|
||||
if (required.length) schema.required = required;
|
||||
return schema;
|
||||
}
|
||||
if (typeName === 'array') {
|
||||
return {
|
||||
type: 'array',
|
||||
items: value.length ? inferJsonSchema(value[0]) : { type: 'string' },
|
||||
};
|
||||
}
|
||||
return { type: typeName };
|
||||
}
|
||||
|
||||
function collectSchemaFields(schema, prefix) {
|
||||
var result = [];
|
||||
var node = schema || {};
|
||||
if (node.type === 'object' && node.properties) {
|
||||
Object.keys(node.properties).forEach(function(name) {
|
||||
var child = node.properties[name] || {};
|
||||
var path = prefix ? prefix + '.' + name : name;
|
||||
if (child.type === 'object' && child.properties) {
|
||||
result = result.concat(collectSchemaFields(child, path));
|
||||
} else {
|
||||
result.push({
|
||||
name: path,
|
||||
required: Array.isArray(node.required) && node.required.indexOf(name) >= 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function requiredSchemaFields(schema) {
|
||||
return collectSchemaFields(schema, '').filter(function(item) {
|
||||
return item.required;
|
||||
}).map(function(item) {
|
||||
return item.name;
|
||||
});
|
||||
}
|
||||
|
||||
function collectJsonLeaves(value, prefix) {
|
||||
var result = [];
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.length) {
|
||||
if (prefix) result.push({ name: prefix, required: true, array: true });
|
||||
return result;
|
||||
}
|
||||
var samplePath = prefix ? prefix + '[0]' : '[0]';
|
||||
return collectJsonLeaves(value[0], samplePath).map(function(item) {
|
||||
item.array = true;
|
||||
return item;
|
||||
});
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
Object.keys(value).forEach(function(name) {
|
||||
var path = prefix ? prefix + '.' + name : name;
|
||||
var child = value[name];
|
||||
if (child && typeof child === 'object') {
|
||||
result = result.concat(collectJsonLeaves(child, path));
|
||||
} else {
|
||||
result.push({ name: path, required: child !== null && child !== undefined });
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
if (prefix) result.push({ name: prefix, required: true });
|
||||
return result;
|
||||
}
|
||||
|
||||
function pathParams() {
|
||||
var path = readText('endpoint-path') || '';
|
||||
var params = [];
|
||||
path.replace(/\{([A-Za-z_][A-Za-z0-9_]*)\}/g, function(_match, name) {
|
||||
params.push(name);
|
||||
return _match;
|
||||
});
|
||||
path.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, function(_match, name) {
|
||||
params.push(name);
|
||||
return _match;
|
||||
});
|
||||
return Array.from(new Set(params));
|
||||
}
|
||||
|
||||
function currentMethod() {
|
||||
var active = document.querySelector('.method-card.active');
|
||||
return active ? active.dataset.method || 'GET' : 'GET';
|
||||
}
|
||||
|
||||
function defaultTargetKind() {
|
||||
var method = currentMethod().toUpperCase();
|
||||
return method === 'GET' || method === 'DELETE' ? 'query' : 'body';
|
||||
}
|
||||
|
||||
function splitRequestTarget(target) {
|
||||
var value = String(target || '').replace(/^\$\.request\./, '').replace(/^request\./, '');
|
||||
var parts = value.split('.');
|
||||
var kind = parts.shift() || defaultTargetKind();
|
||||
if (REQUEST_TARGETS.indexOf(kind) < 0) kind = defaultTargetKind();
|
||||
return { kind: kind, name: parts.join('.') };
|
||||
}
|
||||
|
||||
function sourceToField(source) {
|
||||
return String(source || '')
|
||||
.replace(/^\$\.mcp\.?/, '')
|
||||
.replace(/^\$\.input\.?/, '');
|
||||
}
|
||||
|
||||
function responseSourceToPath(source) {
|
||||
return String(source || '')
|
||||
.replace(/^\$\.response\.body\.?/, '')
|
||||
.replace(/^\$\.response\.data\.?/, '');
|
||||
}
|
||||
|
||||
function outputTargetToField(target) {
|
||||
return String(target || '').replace(/^\$\.output\.?/, '');
|
||||
}
|
||||
|
||||
function mappingEntrySource(entry) {
|
||||
return entry && typeof entry === 'object' && !Array.isArray(entry)
|
||||
? entry.source
|
||||
: entry;
|
||||
}
|
||||
|
||||
function mappingEntryDefault(entry) {
|
||||
return entry && typeof entry === 'object' && !Array.isArray(entry) && Object.prototype.hasOwnProperty.call(entry, 'default_value')
|
||||
? entry.default_value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function mappingEntryTransform(entry) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry) || entry.transform === undefined || entry.transform === null) {
|
||||
return '';
|
||||
}
|
||||
var value = typeof entry.transform === 'object' ? entry.transform.kind : entry.transform;
|
||||
value = String(value || '').trim();
|
||||
return transformKinds().indexOf(value) >= 0 ? value : '';
|
||||
}
|
||||
|
||||
function defaultValueToText(value) {
|
||||
if (value === undefined) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function parseDefaultValue(text) {
|
||||
var value = String(text || '').trim();
|
||||
if (!value) return undefined;
|
||||
if (/^(true|false|null)$/i.test(value) || /^-?(0|[1-9][0-9]*)(\.[0-9]+)?$/.test(value) || /^[\[{]/.test(value)) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (_error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function transformKinds() {
|
||||
return [
|
||||
'to_string',
|
||||
'to_number',
|
||||
'to_boolean',
|
||||
'join',
|
||||
'split',
|
||||
'wrap_array',
|
||||
'unwrap_singleton',
|
||||
];
|
||||
}
|
||||
|
||||
function requestRowsFromEditor() {
|
||||
var parsed = safeJson('tool-input-mapping') || {};
|
||||
return Object.keys(parsed).map(function(target) {
|
||||
var entry = parsed[target];
|
||||
var source = mappingEntrySource(entry);
|
||||
var split = splitRequestTarget(target);
|
||||
return {
|
||||
input: sourceToField(source) || split.name,
|
||||
target: split.kind,
|
||||
apiName: split.name || sourceToField(source),
|
||||
defaultValue: defaultValueToText(mappingEntryDefault(entry)),
|
||||
transform: mappingEntryTransform(entry),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function responseRowsFromEditor() {
|
||||
var parsed = safeJson('tool-output-mapping') || {};
|
||||
return Object.keys(parsed).map(function(outputField) {
|
||||
var source = mappingEntrySource(parsed[outputField]);
|
||||
return {
|
||||
output: outputField,
|
||||
responsePath: responseSourceToPath(source) || outputField,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function suggestedRequestRows() {
|
||||
var rows = [];
|
||||
pathParams().forEach(function(name) {
|
||||
rows.push({ input: name, target: 'path', apiName: name });
|
||||
});
|
||||
|
||||
var sample = safeJson('wizard-input-sample');
|
||||
var fields = [];
|
||||
if (sample && typeof sample === 'object') {
|
||||
fields = collectJsonLeaves(sample, '');
|
||||
}
|
||||
if (!fields.length) {
|
||||
fields = collectSchemaFields(safeJson('tool-input-schema'), '');
|
||||
}
|
||||
|
||||
var pathSet = new Set(rows.map(function(row) { return row.input; }));
|
||||
fields.forEach(function(item) {
|
||||
if (pathSet.has(item.name)) return;
|
||||
rows.push({
|
||||
input: item.name,
|
||||
target: defaultTargetKind(),
|
||||
apiName: item.name,
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function suggestedResponseRows() {
|
||||
var sample = safeJson('wizard-output-sample');
|
||||
var fields = sample && typeof sample === 'object'
|
||||
? collectJsonLeaves(sample, '')
|
||||
: collectSchemaFields(safeJson('tool-output-schema'), '');
|
||||
return fields.map(function(item) {
|
||||
return {
|
||||
output: outputNameFromPath(item.name),
|
||||
responsePath: item.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function outputNameFromPath(path) {
|
||||
return String(path || 'value')
|
||||
.replace(/^\[0\]\.?/, 'item_')
|
||||
.replace(/\[([0-9]+)\]/g, '_$1')
|
||||
.replace(/[^A-Za-z0-9_]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
|| 'value';
|
||||
}
|
||||
|
||||
function joinJsonPath(root, relativePath) {
|
||||
var path = String(relativePath || '').trim();
|
||||
if (!path) return root;
|
||||
return path.charAt(0) === '[' ? root + path : root + '.' + path;
|
||||
}
|
||||
|
||||
function makeOption(value, label, selected) {
|
||||
var option = document.createElement('option');
|
||||
option.value = value;
|
||||
option.textContent = label;
|
||||
option.selected = selected;
|
||||
return option;
|
||||
}
|
||||
|
||||
function makeInput(value, className, placeholder) {
|
||||
var input = document.createElement('input');
|
||||
input.className = className || 'form-input input-mono';
|
||||
input.value = value === undefined || value === null ? '' : String(value);
|
||||
input.placeholder = placeholder || '';
|
||||
input.autocomplete = 'off';
|
||||
input.spellcheck = false;
|
||||
input.addEventListener('input', syncVisualMappingsToYaml);
|
||||
input.addEventListener('change', syncVisualMappingsToYaml);
|
||||
return input;
|
||||
}
|
||||
|
||||
function makeRemoveButton(row) {
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'row-btn row-btn-delete mapping-row-remove';
|
||||
button.title = 'Удалить строку';
|
||||
button.textContent = '×';
|
||||
button.addEventListener('click', function() {
|
||||
row.remove();
|
||||
syncVisualMappingsToYaml();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
function renderRequestRows(rows) {
|
||||
var root = field('wizard-request-mapping-rows');
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
if (!rows.length) rows = suggestedRequestRows();
|
||||
rows.forEach(function(row) {
|
||||
root.appendChild(createRequestRow(row));
|
||||
});
|
||||
if (!suspendSync) syncVisualMappingsToYaml();
|
||||
}
|
||||
|
||||
function createRequestRow(row) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'mapping-row request-mapping-row';
|
||||
item.dataset.mappingRow = 'request';
|
||||
|
||||
var input = makeInput(row.input, 'form-input input-mono mapping-source', 'base');
|
||||
input.dataset.role = 'input';
|
||||
item.appendChild(input);
|
||||
|
||||
var select = document.createElement('select');
|
||||
select.className = 'form-select mapping-target';
|
||||
select.dataset.role = 'target';
|
||||
[
|
||||
['path', 'Path'],
|
||||
['query', 'Query'],
|
||||
['headers', 'Header'],
|
||||
['body', 'Body'],
|
||||
].forEach(function(entry) {
|
||||
select.appendChild(makeOption(entry[0], entry[1], entry[0] === row.target));
|
||||
});
|
||||
select.addEventListener('change', syncVisualMappingsToYaml);
|
||||
item.appendChild(select);
|
||||
|
||||
var apiName = makeInput(row.apiName || row.input, 'form-input input-mono mapping-api-name', 'base');
|
||||
apiName.dataset.role = 'apiName';
|
||||
item.appendChild(apiName);
|
||||
|
||||
var defaultValue = makeInput(row.defaultValue, 'form-input input-mono mapping-default-value', 'по умолчанию');
|
||||
defaultValue.dataset.role = 'defaultValue';
|
||||
defaultValue.title = 'Значение по умолчанию, если поле не передано';
|
||||
item.appendChild(defaultValue);
|
||||
|
||||
var transform = document.createElement('select');
|
||||
transform.className = 'form-select mapping-transform';
|
||||
transform.dataset.role = 'transform';
|
||||
transform.appendChild(makeOption('', 'Без преобразования', !row.transform));
|
||||
transformKinds().forEach(function(kind) {
|
||||
transform.appendChild(makeOption(kind, kind, kind === row.transform));
|
||||
});
|
||||
transform.title = 'Простое преобразование перед отправкой в API';
|
||||
transform.addEventListener('change', syncVisualMappingsToYaml);
|
||||
item.appendChild(transform);
|
||||
|
||||
item.appendChild(makeRemoveButton(item));
|
||||
return item;
|
||||
}
|
||||
|
||||
function renderResponseRows(rows) {
|
||||
var root = field('wizard-response-mapping-rows');
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
if (!rows.length) rows = suggestedResponseRows();
|
||||
rows.forEach(function(row) {
|
||||
root.appendChild(createResponseRow(row));
|
||||
});
|
||||
if (!suspendSync) syncVisualMappingsToYaml();
|
||||
}
|
||||
|
||||
function createResponseRow(row) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'mapping-row response-mapping-row';
|
||||
item.dataset.mappingRow = 'response';
|
||||
|
||||
var responsePath = makeInput(row.responsePath, 'form-input input-mono mapping-response-path', 'rates.EUR');
|
||||
responsePath.dataset.role = 'responsePath';
|
||||
item.appendChild(responsePath);
|
||||
|
||||
var output = makeInput(row.output, 'form-input input-mono mapping-output-field', 'rate');
|
||||
output.dataset.role = 'output';
|
||||
item.appendChild(output);
|
||||
item.appendChild(makeRemoveButton(item));
|
||||
return item;
|
||||
}
|
||||
|
||||
function valueIn(row, role) {
|
||||
var input = row.querySelector('[data-role="' + role + '"]');
|
||||
return input ? input.value.trim() : '';
|
||||
}
|
||||
|
||||
function syncVisualMappingsToYaml() {
|
||||
var requestRows = document.querySelectorAll('[data-mapping-row="request"]');
|
||||
if (requestRows.length) {
|
||||
var requestMapping = {};
|
||||
requestRows.forEach(function(row) {
|
||||
var input = valueIn(row, 'input');
|
||||
var target = valueIn(row, 'target');
|
||||
var apiName = valueIn(row, 'apiName') || input;
|
||||
var defaultText = valueIn(row, 'defaultValue');
|
||||
var transform = valueIn(row, 'transform');
|
||||
if (!input || !target || !apiName) return;
|
||||
if (defaultText || transform) {
|
||||
var entry = { source: '$.input.' + input };
|
||||
if (defaultText) entry.default_value = parseDefaultValue(defaultText);
|
||||
if (transform) entry.transform = transform;
|
||||
requestMapping[target + '.' + apiName] = entry;
|
||||
return;
|
||||
}
|
||||
requestMapping[target + '.' + apiName] = '$.input.' + input;
|
||||
});
|
||||
writeText('tool-input-mapping', dumpYaml(requestMapping));
|
||||
}
|
||||
|
||||
var responseRows = document.querySelectorAll('[data-mapping-row="response"]');
|
||||
if (responseRows.length) {
|
||||
var responseMapping = {};
|
||||
responseRows.forEach(function(row) {
|
||||
var responsePath = valueIn(row, 'responsePath');
|
||||
var output = valueIn(row, 'output') || outputNameFromPath(responsePath);
|
||||
if (!responsePath || !output) return;
|
||||
responseMapping[output] = joinJsonPath('$.response.body', responsePath);
|
||||
});
|
||||
writeText('tool-output-mapping', dumpYaml(responseMapping));
|
||||
}
|
||||
renderMappingWarnings();
|
||||
}
|
||||
|
||||
function currentRequestMappedInputs() {
|
||||
return Array.from(document.querySelectorAll('[data-mapping-row="request"]')).map(function(row) {
|
||||
return valueIn(row, 'input');
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function currentResponseMappedOutputs() {
|
||||
return Array.from(document.querySelectorAll('[data-mapping-row="response"]')).map(function(row) {
|
||||
return valueIn(row, 'output');
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function jsonParseWarning(id, label) {
|
||||
var raw = readText(id);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
parseText(id);
|
||||
return null;
|
||||
} catch (error) {
|
||||
return label + ': JSON не разобран. ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function buildMappingWarnings() {
|
||||
var warnings = [];
|
||||
var inputJsonWarning = jsonParseWarning('wizard-input-sample', 'Пример входных данных');
|
||||
var outputJsonWarning = jsonParseWarning('wizard-output-sample', 'Пример ответа');
|
||||
if (inputJsonWarning) warnings.push(inputJsonWarning);
|
||||
if (outputJsonWarning) warnings.push(outputJsonWarning);
|
||||
|
||||
var required = requiredSchemaFields(safeJson('tool-input-schema'));
|
||||
var mapped = new Set(currentRequestMappedInputs());
|
||||
var missing = required.filter(function(name) { return !mapped.has(name); });
|
||||
if (missing.length) {
|
||||
warnings.push('Обязательные поля входной схемы не отправляются в API: ' + missing.join(', ') + '.');
|
||||
}
|
||||
|
||||
var outputRows = currentResponseMappedOutputs();
|
||||
if (!outputRows.length) {
|
||||
warnings.push('Ответ инструмента пустой. Выберите хотя бы одно поле из ответа API.');
|
||||
}
|
||||
if (outputRows.length > 12) {
|
||||
warnings.push('В ответ инструмента выбрано много полей: ' + outputRows.length + '. Лучше вернуть только данные, нужные агенту.');
|
||||
}
|
||||
var hasArrayIndex = Array.from(document.querySelectorAll('[data-mapping-row="response"]')).some(function(row) {
|
||||
return /\[[0-9]+\]/.test(valueIn(row, 'responsePath'));
|
||||
});
|
||||
if (hasArrayIndex) {
|
||||
warnings.push('В ответе выбрано поле из одного элемента массива. Если агенту нужен весь список, верните массив целиком через расширенный YAML-маппинг.');
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
function renderMappingWarnings() {
|
||||
var root = field('wizard-mapping-warnings');
|
||||
if (!root) return;
|
||||
var warnings = buildMappingWarnings();
|
||||
root.replaceChildren();
|
||||
root.hidden = warnings.length === 0;
|
||||
warnings.forEach(function(message) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'mapping-warning';
|
||||
var title = document.createElement('strong');
|
||||
title.textContent = 'Проверьте маппинг. ';
|
||||
item.appendChild(title);
|
||||
item.appendChild(document.createTextNode(message));
|
||||
root.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function renderResponseTree() {
|
||||
var root = field('wizard-response-json-tree');
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
var sample = safeJson('wizard-output-sample');
|
||||
var leaves = sample && typeof sample === 'object' ? collectJsonLeaves(sample, '') : [];
|
||||
if (!leaves.length) {
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'form-hint';
|
||||
empty.textContent = 'Добавьте пример ответа JSON, чтобы выбрать поля кликом.';
|
||||
root.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
leaves.forEach(function(leaf) {
|
||||
var button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'json-tree-node';
|
||||
button.textContent = leaf.name;
|
||||
button.addEventListener('click', function() {
|
||||
var output = outputNameFromPath(leaf.name);
|
||||
field('wizard-response-mapping-rows').appendChild(createResponseRow({
|
||||
responsePath: leaf.name,
|
||||
output: output,
|
||||
}));
|
||||
syncVisualMappingsToYaml();
|
||||
});
|
||||
root.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
function formatSamplesAndInferSchemas() {
|
||||
var input = parseText('wizard-input-sample');
|
||||
var output = parseText('wizard-output-sample');
|
||||
writeText('wizard-input-sample', JSON.stringify(input, null, 2));
|
||||
writeText('wizard-test-input', JSON.stringify(input, null, 2));
|
||||
writeText('wizard-output-sample', JSON.stringify(output, null, 2));
|
||||
writeText('tool-input-schema', JSON.stringify(inferJsonSchema(input), null, 2));
|
||||
writeText('tool-output-schema', JSON.stringify(inferJsonSchema(output), null, 2));
|
||||
renderRequestRows(suggestedRequestRows());
|
||||
renderResponseRows(suggestedResponseRows());
|
||||
renderResponseTree();
|
||||
if (window.CrankWizardLive && typeof window.CrankWizardLive.renderAgentFacingPreview === 'function') {
|
||||
window.CrankWizardLive.renderAgentFacingPreview();
|
||||
}
|
||||
}
|
||||
|
||||
function loadJsonFile(inputId, targetTextareaId, afterLoad) {
|
||||
var input = field(inputId);
|
||||
if (!input) return;
|
||||
var file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(event) {
|
||||
try {
|
||||
var parsed = JSON.parse(event.target.result || '{}');
|
||||
writeText(targetTextareaId, JSON.stringify(parsed, null, 2));
|
||||
if (afterLoad) afterLoad();
|
||||
} catch (error) {
|
||||
if (window.CrankUi) {
|
||||
window.CrankUi.error(error.message, 'JSON не прочитан');
|
||||
}
|
||||
} finally {
|
||||
input.value = '';
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function bindFileButton(buttonId, inputId) {
|
||||
var button = field(buttonId);
|
||||
var input = field(inputId);
|
||||
if (!button || !input || button.dataset.mappingBound === 'true') return;
|
||||
button.dataset.mappingBound = 'true';
|
||||
button.addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
function bindOnce(id, eventName, handler) {
|
||||
var element = field(id);
|
||||
if (!element || element.dataset.mappingBound === 'true') return;
|
||||
element.dataset.mappingBound = 'true';
|
||||
element.addEventListener(eventName, handler);
|
||||
}
|
||||
|
||||
function initializeMappingBuilder() {
|
||||
bindOnce('wizard-add-request-mapping-row', 'click', function(event) {
|
||||
event.preventDefault();
|
||||
field('wizard-request-mapping-rows').appendChild(createRequestRow({
|
||||
input: '',
|
||||
target: defaultTargetKind(),
|
||||
apiName: '',
|
||||
}));
|
||||
});
|
||||
bindOnce('wizard-add-response-mapping-row', 'click', function(event) {
|
||||
event.preventDefault();
|
||||
field('wizard-response-mapping-rows').appendChild(createResponseRow({
|
||||
responsePath: '',
|
||||
output: '',
|
||||
}));
|
||||
});
|
||||
bindOnce('wizard-auto-request-mapping', 'click', function(event) {
|
||||
event.preventDefault();
|
||||
renderRequestRows(suggestedRequestRows());
|
||||
});
|
||||
bindOnce('wizard-auto-response-mapping', 'click', function(event) {
|
||||
event.preventDefault();
|
||||
renderResponseRows(suggestedResponseRows());
|
||||
renderResponseTree();
|
||||
});
|
||||
bindOnce('wizard-sync-request-mapping', 'click', function(event) {
|
||||
event.preventDefault();
|
||||
syncVisualMappingsToYaml();
|
||||
});
|
||||
bindOnce('wizard-sync-response-mapping', 'click', function(event) {
|
||||
event.preventDefault();
|
||||
syncVisualMappingsToYaml();
|
||||
});
|
||||
bindOnce('wizard-format-samples', 'click', function(event) {
|
||||
event.preventDefault();
|
||||
formatSamplesAndInferSchemas();
|
||||
});
|
||||
|
||||
bindFileButton('wizard-load-input-sample-file', 'wizard-input-sample-file');
|
||||
bindFileButton('wizard-load-output-sample-file', 'wizard-output-sample-file');
|
||||
bindOnce('wizard-input-sample-file', 'change', function() {
|
||||
loadJsonFile('wizard-input-sample-file', 'wizard-input-sample', function() {
|
||||
renderRequestRows(suggestedRequestRows());
|
||||
});
|
||||
});
|
||||
bindOnce('wizard-output-sample-file', 'change', function() {
|
||||
loadJsonFile('wizard-output-sample-file', 'wizard-output-sample', function() {
|
||||
renderResponseRows(suggestedResponseRows());
|
||||
renderResponseTree();
|
||||
});
|
||||
});
|
||||
|
||||
['wizard-input-sample', 'tool-input-schema', 'endpoint-path'].forEach(function(id) {
|
||||
var element = field(id);
|
||||
if (!element || element.dataset.mappingRefreshBound === 'true') return;
|
||||
element.dataset.mappingRefreshBound = 'true';
|
||||
element.addEventListener('change', function() {
|
||||
renderRequestRows(requestRowsFromEditor());
|
||||
renderMappingWarnings();
|
||||
});
|
||||
});
|
||||
['wizard-output-sample', 'tool-output-schema'].forEach(function(id) {
|
||||
var element = field(id);
|
||||
if (!element || element.dataset.mappingRefreshBound === 'true') return;
|
||||
element.dataset.mappingRefreshBound = 'true';
|
||||
element.addEventListener('change', function() {
|
||||
renderResponseRows(responseRowsFromEditor());
|
||||
renderResponseTree();
|
||||
renderMappingWarnings();
|
||||
});
|
||||
});
|
||||
|
||||
renderRequestRows(requestRowsFromEditor());
|
||||
renderResponseRows(responseRowsFromEditor());
|
||||
renderResponseTree();
|
||||
renderMappingWarnings();
|
||||
}
|
||||
|
||||
window.CrankWizardMapping = {
|
||||
initialize: initializeMappingBuilder,
|
||||
sync: syncVisualMappingsToYaml,
|
||||
renderFromEditors: function() {
|
||||
suspendSync = true;
|
||||
renderRequestRows(requestRowsFromEditor());
|
||||
renderResponseRows(responseRowsFromEditor());
|
||||
suspendSync = false;
|
||||
syncVisualMappingsToYaml();
|
||||
renderResponseTree();
|
||||
renderMappingWarnings();
|
||||
},
|
||||
formatSamplesAndInferSchemas: formatSamplesAndInferSchemas,
|
||||
renderWarnings: renderMappingWarnings,
|
||||
};
|
||||
})();
|
||||
+101
-7
@@ -122,26 +122,76 @@ function preserveMappingRuleMetadata(rule, existingRule) {
|
||||
return rule;
|
||||
}
|
||||
|
||||
function mappingEntrySource(entry) {
|
||||
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
|
||||
return entry.source;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function normalizeTransformKind(value) {
|
||||
if (!value) return null;
|
||||
var kind = value && typeof value === 'object' ? value.kind : value;
|
||||
kind = String(kind || '').trim();
|
||||
var allowed = [
|
||||
'identity',
|
||||
'to_string',
|
||||
'to_number',
|
||||
'to_boolean',
|
||||
'join',
|
||||
'split',
|
||||
'wrap_array',
|
||||
'unwrap_singleton',
|
||||
];
|
||||
return allowed.indexOf(kind) >= 0 ? kind : null;
|
||||
}
|
||||
|
||||
function applyMappingEntryMetadata(rule, entry, existingRule) {
|
||||
preserveMappingRuleMetadata(rule, existingRule);
|
||||
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
||||
return rule;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(entry, 'required')) {
|
||||
rule.required = entry.required !== false;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(entry, 'default_value')) {
|
||||
rule.default_value = entry.default_value;
|
||||
}
|
||||
if (entry.transform !== undefined && entry.transform !== null) {
|
||||
var transformKind = normalizeTransformKind(entry.transform);
|
||||
if (transformKind) rule.transform = { kind: transformKind };
|
||||
}
|
||||
['condition', 'notes'].forEach(function(field) {
|
||||
if (entry[field] !== undefined && entry[field] !== null) {
|
||||
rule[field] = entry[field];
|
||||
}
|
||||
});
|
||||
return rule;
|
||||
}
|
||||
|
||||
function buildMappingSet(rawValue, mode) {
|
||||
var value = rawValue || {};
|
||||
var rules = [];
|
||||
|
||||
Object.keys(value).forEach(function(target) {
|
||||
var source = value[target];
|
||||
var entry = value[target];
|
||||
var source = mappingEntrySource(entry);
|
||||
if (mode === 'input') {
|
||||
var inputTarget = normalizeInputTarget(target);
|
||||
rules.push(preserveMappingRuleMetadata({
|
||||
rules.push(applyMappingEntryMetadata({
|
||||
source: normalizeInputSource(source),
|
||||
target: inputTarget,
|
||||
}, existingMappingRuleByTarget(mode, inputTarget)));
|
||||
}, entry, existingMappingRuleByTarget(mode, inputTarget)));
|
||||
return;
|
||||
}
|
||||
|
||||
var outputTarget = normalizeOutputTarget(target);
|
||||
rules.push(preserveMappingRuleMetadata({
|
||||
rules.push(applyMappingEntryMetadata({
|
||||
source: normalizeOutputSource(source),
|
||||
target: outputTarget,
|
||||
}, existingMappingRuleByTarget(mode, outputTarget)));
|
||||
}, entry, existingMappingRuleByTarget(mode, outputTarget)));
|
||||
});
|
||||
|
||||
return { rules: rules };
|
||||
@@ -313,11 +363,52 @@ function mappingSetToEditorValue(mappingSet, mode, protocol) {
|
||||
var key = rule.target.indexOf(mappingRootForProtocol(protocol) + '.') === 0
|
||||
? rule.target.replace(mappingRootForProtocol(protocol) + '.', '')
|
||||
: rule.target.replace('$.request.', '');
|
||||
object[key] = rule.source.replace('$.mcp.', '$.input.');
|
||||
var inputSource = rule.source.replace('$.mcp.', '$.input.');
|
||||
var inputEntry = { source: inputSource };
|
||||
var hasMetadata = false;
|
||||
if (rule.required === false) {
|
||||
inputEntry.required = false;
|
||||
hasMetadata = true;
|
||||
}
|
||||
if (rule.default_value !== undefined && rule.default_value !== null) {
|
||||
inputEntry.default_value = rule.default_value;
|
||||
hasMetadata = true;
|
||||
}
|
||||
if (rule.transform && rule.transform.kind) {
|
||||
inputEntry.transform = rule.transform.kind;
|
||||
hasMetadata = true;
|
||||
}
|
||||
['condition', 'notes'].forEach(function(field) {
|
||||
if (rule[field] !== undefined && rule[field] !== null) {
|
||||
inputEntry[field] = rule[field];
|
||||
hasMetadata = true;
|
||||
}
|
||||
});
|
||||
object[key] = hasMetadata ? inputEntry : inputSource;
|
||||
return;
|
||||
}
|
||||
var outputKey = rule.target.replace('$.output.', '');
|
||||
object[outputKey] = rule.source;
|
||||
var outputEntry = { source: rule.source };
|
||||
var outputHasMetadata = false;
|
||||
if (rule.required === false) {
|
||||
outputEntry.required = false;
|
||||
outputHasMetadata = true;
|
||||
}
|
||||
if (rule.default_value !== undefined && rule.default_value !== null) {
|
||||
outputEntry.default_value = rule.default_value;
|
||||
outputHasMetadata = true;
|
||||
}
|
||||
if (rule.transform && rule.transform.kind) {
|
||||
outputEntry.transform = rule.transform.kind;
|
||||
outputHasMetadata = true;
|
||||
}
|
||||
['condition', 'notes'].forEach(function(field) {
|
||||
if (rule[field] !== undefined && rule[field] !== null) {
|
||||
outputEntry[field] = rule[field];
|
||||
outputHasMetadata = true;
|
||||
}
|
||||
});
|
||||
object[outputKey] = outputHasMetadata ? outputEntry : rule.source;
|
||||
});
|
||||
return window.jsyaml ? window.jsyaml.dump(object, { lineWidth: -1 }) : JSON.stringify(object, null, 2);
|
||||
}
|
||||
@@ -397,6 +488,9 @@ function prefillWizardFromEdit(detail, versionDocument) {
|
||||
setValue('tool-output-mapping', mappingSetToEditorValue(snapshot.output_mapping, 'output', snapshot.protocol || detail.protocol));
|
||||
setValue('tool-exec-config', executionConfigToEditorValue(snapshot.execution_config || {}));
|
||||
prefillWizardSamples(snapshot);
|
||||
if (window.CrankWizardMapping && typeof window.CrankWizardMapping.renderFromEditors === 'function') {
|
||||
window.CrankWizardMapping.renderFromEditors();
|
||||
}
|
||||
|
||||
var toolNameInput = document.getElementById('tool-name');
|
||||
if (toolNameInput) toolNameInput.disabled = true;
|
||||
|
||||
@@ -41,6 +41,7 @@ const BUNDLES = {
|
||||
operations: {
|
||||
files: [
|
||||
'node_modules/alpinejs/dist/cdn.min.js',
|
||||
'js/openapi-import.js',
|
||||
'js/catalog.js',
|
||||
],
|
||||
},
|
||||
@@ -93,6 +94,7 @@ const BUNDLES = {
|
||||
'js/wizard-shell.js',
|
||||
'js/wizard-upstreams.js',
|
||||
'js/wizard-model.js',
|
||||
'js/wizard-mapping.js',
|
||||
'js/wizard-live.js',
|
||||
'js/wizard.js',
|
||||
'js/nav.js',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { login, localized } = require('./helpers');
|
||||
const { login, localized, uniqueName } = require('./helpers');
|
||||
|
||||
test('operations page shows demo catalog and filter works', async ({ page }) => {
|
||||
await login(page);
|
||||
@@ -11,3 +11,109 @@ test('operations page shows demo catalog and filter works', async ({ page }) =>
|
||||
await expect(page.locator('tbody tr')).toHaveCount(1);
|
||||
await expect(page.locator('tbody tr').first()).toContainText(/frankfurter_latest_rate/i);
|
||||
});
|
||||
|
||||
test('operations page imports OpenAPI methods as drafts', async ({ page }) => {
|
||||
await login(page);
|
||||
const operationId = uniqueName('get_rates');
|
||||
const statusOperationId = `${operationId}_status`;
|
||||
await page.getByRole('button', { name: /Импорт OpenAPI/i }).click();
|
||||
await expect(page.locator('#openapi-import-modal')).toBeVisible();
|
||||
await page.locator('#openapi-import-document').fill(`
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Demo Import API
|
||||
servers:
|
||||
paths:
|
||||
/v1/rates/{date}:
|
||||
post:
|
||||
operationId: ${operationId}
|
||||
summary: Получить курсы валют
|
||||
description: Курсы.
|
||||
tags: [currency]
|
||||
parameters:
|
||||
- name: date
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: base
|
||||
in: query
|
||||
required: true
|
||||
schema: { type: string }
|
||||
- name: X-API-Version
|
||||
in: header
|
||||
required: false
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [symbols]
|
||||
properties:
|
||||
symbols: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
base: { type: string }
|
||||
/v1/status:
|
||||
get:
|
||||
operationId: ${statusOperationId}
|
||||
summary: Проверить статус
|
||||
tags: [service]
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status: { type: string }
|
||||
`);
|
||||
await page.locator('#openapi-import-preview').click();
|
||||
await expect(page.locator('#openapi-import-preview-panel')).toBeVisible();
|
||||
await expect(page.locator('.openapi-import-document-findings')).toContainText(/base URL/i);
|
||||
await expect(page.locator('.openapi-import-group').filter({ hasText: 'currency' })).toBeVisible();
|
||||
const importedOperation = page.locator('.openapi-import-operation').filter({ hasText: 'Получить курсы валют' });
|
||||
await expect(importedOperation).toBeVisible();
|
||||
await expect(page.locator('.openapi-import-operation').filter({ hasText: 'Проверить статус' })).toBeVisible();
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Path');
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('date');
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Query');
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('base');
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Header');
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('X-API-Version');
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Body');
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('symbols');
|
||||
await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Ответ');
|
||||
await expect(page.locator('#openapi-import-selection')).toContainText('Выбрано: 2');
|
||||
await page.locator('#openapi-import-search').fill('status');
|
||||
await expect(page.locator('.openapi-import-operation:visible')).toHaveCount(1);
|
||||
await expect(page.locator('.openapi-import-operation:visible').filter({ hasText: 'Проверить статус' })).toBeVisible();
|
||||
await page.locator('#openapi-import-clear-visible').click();
|
||||
await expect(page.locator('#openapi-import-selection')).toContainText('Выбрано: 1');
|
||||
await page.locator('#openapi-import-search').fill('');
|
||||
await page.locator('#openapi-import-method-filter').selectOption('POST');
|
||||
await expect(page.locator('.openapi-import-operation:visible')).toHaveCount(1);
|
||||
await expect(page.locator('.openapi-import-operation:visible').filter({ hasText: 'Получить курсы валют' })).toBeVisible();
|
||||
await page.locator('#openapi-import-method-filter').selectOption('');
|
||||
await page.locator('#openapi-import-server-custom').fill('https://api.example.test');
|
||||
await page.locator('#openapi-import-create').click();
|
||||
await expect(page.locator('#openapi-import-result')).toContainText('Результат импорта');
|
||||
await expect(page.locator('.openapi-import-result-table')).toBeVisible();
|
||||
await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText('POST /v1/rates/{date}');
|
||||
await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText(/Описание инструмента слишком короткое/);
|
||||
await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText('Исправить в мастере');
|
||||
await expect(page.locator('.openapi-import-primary-result a')).toHaveAttribute(
|
||||
'href',
|
||||
/\/wizard\/\?mode=edit&operationId=op_/
|
||||
);
|
||||
await expect(page.locator('tbody')).toContainText(new RegExp(operationId, 'i'));
|
||||
await expect(page.locator('tbody')).not.toContainText(new RegExp(statusOperationId, 'i'));
|
||||
});
|
||||
|
||||
@@ -122,6 +122,7 @@ test('wizard loads reusable upstreams from backend', async ({ page }) => {
|
||||
);
|
||||
|
||||
await page.goto('/wizard/');
|
||||
await expect(page.locator('#step-panel-1')).toBeVisible();
|
||||
await page.locator('[data-testid="wizard-protocol-rest"]').click();
|
||||
await page.locator('.btn-continue').click();
|
||||
await expect(page.locator('#step-panel-2')).toBeVisible();
|
||||
@@ -132,6 +133,69 @@ test('wizard loads reusable upstreams from backend', async ({ page }) => {
|
||||
await expect(page.locator('#upstream-dropdown-list')).not.toContainText('Open Meteo');
|
||||
});
|
||||
|
||||
test('wizard builds visual request mappings from JSON sample and path params', async ({ page }) => {
|
||||
await login(page);
|
||||
|
||||
await page.goto('/wizard/');
|
||||
await page.locator('[data-testid="wizard-protocol-rest"]').click();
|
||||
await page.evaluate(() => window.CrankWizardShell.doGoToStep(2));
|
||||
await expect(page.locator('#step-panel-2')).toBeVisible();
|
||||
await page.locator('#endpoint-path').fill('/rates/{date}');
|
||||
|
||||
await page.evaluate(() => window.CrankWizardShell.doGoToStep(3));
|
||||
await expect(page.locator('#step-panel-3-rest')).toBeVisible();
|
||||
await page.locator('.method-card[data-method="GET"]').click();
|
||||
|
||||
await page.evaluate(() => window.CrankWizardShell.doGoToStep(5));
|
||||
await expect(page.locator('#step-panel-5')).toBeVisible();
|
||||
|
||||
await page.locator('#wizard-input-sample').fill(JSON.stringify({
|
||||
date: '2024-01-01',
|
||||
base: 'USD',
|
||||
symbols: 'EUR',
|
||||
}, null, 2));
|
||||
await page.locator('#wizard-output-sample').fill(JSON.stringify({
|
||||
base: 'USD',
|
||||
rates: {
|
||||
EUR: 0.91,
|
||||
},
|
||||
providers: [
|
||||
{
|
||||
name: 'ecb',
|
||||
priority: 1,
|
||||
},
|
||||
],
|
||||
}, null, 2));
|
||||
|
||||
await page.locator('#wizard-format-samples').click();
|
||||
|
||||
await expect(page.locator('[data-testid="wizard-request-mapping-rows"] [data-role="input"]').first()).toHaveValue('date');
|
||||
await expect(page.locator('[data-testid="wizard-request-mapping-rows"] [data-role="input"]').nth(1)).toHaveValue('base');
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/path\.date/);
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/query\.base/);
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/query\.symbols/);
|
||||
await expect(page.locator('#tool-input-schema')).toHaveValue(/"date"/);
|
||||
await expect(page.locator('#tool-output-schema')).toHaveValue(/"rates"/);
|
||||
await expect(page.locator('[data-testid="wizard-response-json-tree"]')).toContainText('rates.EUR');
|
||||
await expect(page.locator('[data-testid="wizard-response-json-tree"]')).toContainText('providers[0].name');
|
||||
await expect(page.locator('#tool-output-mapping')).toHaveValue(/providers_0_name: \$\.response\.body\.providers\[0\]\.name/);
|
||||
await expect(page.locator('[data-testid="wizard-mapping-warnings"]')).toContainText(/одного элемента массива/);
|
||||
|
||||
await page.locator('#wizard-add-request-mapping-row').click();
|
||||
const defaultRow = page.locator('[data-testid="wizard-request-mapping-rows"] [data-mapping-row="request"]').last();
|
||||
await defaultRow.locator('[data-role="input"]').fill('group');
|
||||
await defaultRow.locator('[data-role="target"]').selectOption('query');
|
||||
await defaultRow.locator('[data-role="apiName"]').fill('group');
|
||||
await defaultRow.locator('[data-role="defaultValue"]').fill('month');
|
||||
await defaultRow.locator('[data-role="transform"]').selectOption('to_string');
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/query\.group:/);
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/default_value: month/);
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/transform: to_string/);
|
||||
|
||||
await page.locator('[data-testid="wizard-request-mapping-rows"] .mapping-row-remove').first().click();
|
||||
await expect(page.locator('[data-testid="wizard-mapping-warnings"]')).toContainText(/date/);
|
||||
});
|
||||
|
||||
test('wizard edit mode hydrates fields from operation version snapshot', async ({ page }) => {
|
||||
await login(page);
|
||||
const workspace = await getCurrentWorkspace(page);
|
||||
@@ -301,6 +365,15 @@ test('wizard edit mode hydrates fields from operation version snapshot', async (
|
||||
from: 'GBP',
|
||||
to: 'USD',
|
||||
},
|
||||
import_findings: [
|
||||
{
|
||||
severity: 'warning',
|
||||
code: 'openapi_import.weak_tool_description',
|
||||
message: 'Описание инструмента слишком короткое или техническое.',
|
||||
suggested_action: 'Уточните, когда агент должен вызывать инструмент.',
|
||||
field_path: 'GET /rates',
|
||||
},
|
||||
],
|
||||
},
|
||||
samples: [],
|
||||
generated_draft: null,
|
||||
@@ -338,6 +411,13 @@ test('wizard edit mode hydrates fields from operation version snapshot', async (
|
||||
await expect(page.locator('#wizard-test-input')).toHaveValue(/"from": "GBP"/);
|
||||
await expect(page.locator('#wizard-test-input')).toHaveValue(/"to": "USD"/);
|
||||
await expect(page.locator('#wizard-test-input')).not.toHaveValue(/Ada/);
|
||||
|
||||
await page.evaluate(() => window.CrankWizardShell.doGoToStep(5));
|
||||
await expect(page.locator('#wizard-quality-findings')).toContainText('Описание инструмента слишком короткое');
|
||||
await expect(page.locator('#wizard-quality-findings')).toContainText('GET /rates');
|
||||
await page.getByRole('button', { name: 'Перейти к описанию' }).click();
|
||||
await expect(page.locator('#step-panel-4')).toBeVisible();
|
||||
await expect(page.locator('#tool-description')).toBeFocused();
|
||||
});
|
||||
|
||||
test('wizard shows agent-facing MCP preview from current draft fields', async ({ page }) => {
|
||||
@@ -669,6 +749,7 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
target: '$.request.query.group',
|
||||
required: false,
|
||||
default_value: 'month',
|
||||
transform: { kind: 'to_string' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -706,6 +787,15 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
base: 'CHF',
|
||||
symbols: 'JPY',
|
||||
},
|
||||
import_findings: [
|
||||
{
|
||||
severity: 'warning',
|
||||
code: 'openapi_import.weak_tool_description',
|
||||
message: 'Описание инструмента слишком короткое или техническое.',
|
||||
suggested_action: 'Уточните описание.',
|
||||
field_path: 'GET /latest',
|
||||
},
|
||||
],
|
||||
},
|
||||
samples: [],
|
||||
generated_draft: null,
|
||||
@@ -719,6 +809,7 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
await page.goto(`/wizard/?mode=edit&operationId=${operationId}`);
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/query\.base/);
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/path\.date/);
|
||||
await expect(page.locator('#tool-input-mapping')).toHaveValue(/transform: to_string/);
|
||||
await page.locator('.btn-save-draft').click();
|
||||
await expect.poll(() => updatePayload).not.toBeNull();
|
||||
|
||||
@@ -766,6 +857,15 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
base: 'CHF',
|
||||
symbols: 'JPY',
|
||||
},
|
||||
import_findings: [
|
||||
{
|
||||
severity: 'warning',
|
||||
code: 'openapi_import.weak_tool_description',
|
||||
message: 'Описание инструмента слишком короткое или техническое.',
|
||||
suggested_action: 'Уточните описание.',
|
||||
field_path: 'GET /latest',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(updatePayload.input_mapping.rules).toEqual([
|
||||
{ source: '$.mcp.base', target: '$.request.query.base', required: true },
|
||||
@@ -776,6 +876,7 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
|
||||
target: '$.request.query.group',
|
||||
required: false,
|
||||
default_value: 'month',
|
||||
transform: { kind: 'to_string' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::{
|
||||
edition::OperationSecurityLevel,
|
||||
ids::{AuthProfileId, OperationId, SampleId},
|
||||
protocol::{ExportMode, HttpMethod, Protocol},
|
||||
tool_quality::ToolQualityFinding,
|
||||
};
|
||||
|
||||
fn default_operation_category() -> String {
|
||||
@@ -180,6 +181,8 @@ pub struct WizardState {
|
||||
pub output_sample: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub test_input: Option<Value>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub import_findings: Vec<ToolQualityFinding>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "crank-import"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
crank-core = { path = "../crank-core" }
|
||||
crank-mapping = { path = "../crank-mapping" }
|
||||
crank-schema = { path = "../crank-schema" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
mod unit {
|
||||
use crank_core::HttpMethod;
|
||||
use crank_import::rest::preview_document;
|
||||
|
||||
const OPENAPI3: &str = r#"
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Frankfurter API
|
||||
servers:
|
||||
- url: https://api.frankfurter.dev
|
||||
paths:
|
||||
/v2/latest:
|
||||
get:
|
||||
operationId: getLatestRates
|
||||
summary: Получить последние курсы
|
||||
description: Возвращает последние курсы валют для базовой валюты.
|
||||
tags: [currency]
|
||||
parameters:
|
||||
- name: base
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: symbols
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [amount, base]
|
||||
properties:
|
||||
amount:
|
||||
type: number
|
||||
base:
|
||||
type: string
|
||||
"#;
|
||||
|
||||
const SWAGGER2: &str = r#"
|
||||
swagger: "2.0"
|
||||
info:
|
||||
title: Pet API
|
||||
host: petstore.example.com
|
||||
basePath: /api
|
||||
schemes: [https]
|
||||
paths:
|
||||
/pets/{id}:
|
||||
get:
|
||||
operationId: getPet
|
||||
summary: Получить питомца
|
||||
tags: [pets]
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/Pet'
|
||||
definitions:
|
||||
Pet:
|
||||
type: object
|
||||
required: [id, name]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn previews_openapi3_rest_operations_grouped_by_tag() {
|
||||
let preview = preview_document(OPENAPI3).unwrap();
|
||||
|
||||
assert_eq!(preview.source.format, "openapi");
|
||||
assert_eq!(preview.source.servers, vec!["https://api.frankfurter.dev"]);
|
||||
assert_eq!(preview.groups.len(), 1);
|
||||
assert_eq!(preview.groups[0].key, "currency");
|
||||
let operation = &preview.groups[0].operations[0];
|
||||
assert_eq!(operation.method, HttpMethod::Get);
|
||||
assert_eq!(operation.suggested_name, "get_latest_rates");
|
||||
assert_eq!(operation.input_fields, 2);
|
||||
assert_eq!(operation.output_fields, 2);
|
||||
assert_eq!(operation.draft.target.path_template, "/v2/latest");
|
||||
assert_eq!(operation.draft.input_mapping.rules.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_swagger2_and_resolves_definitions() {
|
||||
let preview = preview_document(SWAGGER2).unwrap();
|
||||
|
||||
assert_eq!(preview.source.format, "swagger");
|
||||
assert_eq!(
|
||||
preview.source.servers,
|
||||
vec!["https://petstore.example.com/api"]
|
||||
);
|
||||
let operation = &preview.groups[0].operations[0];
|
||||
assert_eq!(operation.suggested_name, "get_pet");
|
||||
assert_eq!(operation.input_fields, 1);
|
||||
assert_eq!(operation.output_fields, 2);
|
||||
assert_eq!(operation.draft.target.path_template, "/pets/{id}");
|
||||
assert_eq!(
|
||||
operation.draft.input_mapping.rules[0].target,
|
||||
"$.request.path.id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_missing_descriptions_as_recommendations() {
|
||||
let document = r#"
|
||||
openapi: 3.0.3
|
||||
info: { title: Minimal API }
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
responses:
|
||||
'204': { description: Empty }
|
||||
"#;
|
||||
|
||||
let preview = preview_document(document).unwrap();
|
||||
let operation = &preview.groups[0].operations[0];
|
||||
let codes = operation
|
||||
.findings
|
||||
.iter()
|
||||
.map(|finding| finding.code.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(codes.contains(&"missing_operation_id"));
|
||||
assert!(codes.contains(&"missing_summary"));
|
||||
assert!(codes.contains(&"missing_description"));
|
||||
assert!(codes.contains(&"missing_response_schema"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expands_json_request_body_object_into_tool_inputs() {
|
||||
let document = r#"
|
||||
openapi: 3.0.3
|
||||
info: { title: CRM API }
|
||||
servers:
|
||||
- url: https://crm.example.test
|
||||
paths:
|
||||
/leads:
|
||||
post:
|
||||
operationId: createLead
|
||||
summary: Создать лид
|
||||
description: Создает лид в CRM.
|
||||
tags: [crm]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [email]
|
||||
properties:
|
||||
email: { type: string }
|
||||
name: { type: string }
|
||||
responses:
|
||||
'201':
|
||||
description: Created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
"#;
|
||||
|
||||
let preview = preview_document(document).unwrap();
|
||||
let operation = &preview.groups[0].operations[0];
|
||||
let input_fields = &operation.draft.input_schema.fields;
|
||||
let targets = operation
|
||||
.draft
|
||||
.input_mapping
|
||||
.rules
|
||||
.iter()
|
||||
.map(|rule| rule.target.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(input_fields.contains_key("email"));
|
||||
assert!(input_fields.contains_key("name"));
|
||||
assert!(targets.contains(&"$.request.body.email"));
|
||||
assert!(targets.contains(&"$.request.body.name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_tool_quality_recommendations_for_imported_operations() {
|
||||
let document = r#"
|
||||
openapi: 3.0.3
|
||||
info: { title: Wide API }
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: getItems
|
||||
summary: Get items
|
||||
description: Get items.
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
field01: { type: string }
|
||||
field02: { type: string }
|
||||
field03: { type: string }
|
||||
field04: { type: string }
|
||||
field05: { type: string }
|
||||
field06: { type: string }
|
||||
field07: { type: string }
|
||||
field08: { type: string }
|
||||
field09: { type: string }
|
||||
field10: { type: string }
|
||||
field11: { type: string }
|
||||
field12: { type: string }
|
||||
field13: { type: string }
|
||||
"#;
|
||||
|
||||
let preview = preview_document(document).unwrap();
|
||||
let operation = &preview.groups[0].operations[0];
|
||||
let codes = operation
|
||||
.findings
|
||||
.iter()
|
||||
.map(|finding| finding.code.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert!(codes.contains(&"parameter_descriptions_missing"));
|
||||
assert!(codes.contains(&"weak_tool_description"));
|
||||
assert!(codes.contains(&"weak_tool_name"));
|
||||
assert!(codes.contains(&"too_many_output_fields"));
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,8 @@ pub enum RegistryError {
|
||||
AuthProfileNotFound { auth_profile_id: String },
|
||||
#[error("yaml import job {job_id} was not found")]
|
||||
YamlImportJobNotFound { job_id: String },
|
||||
#[error("import job {job_id} was not found")]
|
||||
ImportJobNotFound { job_id: String },
|
||||
#[error("unsupported enum representation for field {field}")]
|
||||
InvalidEnumRepresentation { field: &'static str },
|
||||
#[error("invalid numeric value for field {field}: {value}")]
|
||||
|
||||
@@ -10,10 +10,11 @@ pub use ext::{ExtensionMigration, RegistryExtension, apply_extension_migrations}
|
||||
pub mod records {
|
||||
pub use crate::model::{
|
||||
AgentSummary, AgentVersionRecord, AuthUserRecord, DescriptorKind, DescriptorMetadata,
|
||||
InvitationRecord, InvocationLogRecord, MembershipRecord, OperationAgentRef,
|
||||
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
|
||||
Page, PlatformApiKeyRecord, PublishedAgentTool, RegistryOperation, SampleKind,
|
||||
SecretRecord, SecretVersionRecord, SessionRecord, UsageAgentBreakdown, UsageBucket,
|
||||
ImportJob, ImportJobId, ImportJobKind, ImportJobStatus, InvitationRecord,
|
||||
InvocationLogRecord, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
|
||||
OperationSummary, OperationUsageSummary, OperationVersionRecord, Page,
|
||||
PlatformApiKeyRecord, PublishedAgentTool, RegistryOperation, SampleKind, SecretRecord,
|
||||
SecretVersionRecord, SessionRecord, UsageAgentBreakdown, UsageBucket,
|
||||
UsageOperationBreakdown, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
||||
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
|
||||
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
@@ -22,13 +23,13 @@ pub mod records {
|
||||
|
||||
pub mod requests {
|
||||
pub use crate::model::{
|
||||
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateInvitationRequest,
|
||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
|
||||
ListInvocationLogsQuery, PublishAgentRequest, PublishRequest, RotateSecretRequest,
|
||||
SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, UpdateWorkspaceRequest,
|
||||
UsageQuery,
|
||||
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateImportJobRequest,
|
||||
CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
|
||||
CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest,
|
||||
CreateYamlImportJobRequest, FinishImportJobRequest, ListInvocationLogsQuery,
|
||||
PublishAgentRequest, PublishRequest, RotateSecretRequest, SaveAgentBindingsRequest,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
SaveWorkspaceUpstreamRequest, UpdateWorkspaceRequest, UsageQuery,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,18 +40,19 @@ pub mod infrastructure {
|
||||
|
||||
pub use model::{
|
||||
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentDraftVersionRequest,
|
||||
CreateAgentRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest,
|
||||
CreateYamlImportJobRequest, DescriptorKind, DescriptorMetadata, InvitationRecord,
|
||||
InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
|
||||
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord, Page,
|
||||
PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool,
|
||||
RegistryOperation, RotateSecretRequest, SampleKind, SaveAgentBindingsRequest,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord, SessionRecord,
|
||||
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
|
||||
UsageRollupRecord, UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord,
|
||||
WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId, YamlImportJob,
|
||||
YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
CreateAgentRequest, CreateImportJobRequest, CreateInvitationRequest,
|
||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind,
|
||||
DescriptorMetadata, FinishImportJobRequest, ImportJob, ImportJobId, ImportJobKind,
|
||||
ImportJobStatus, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery,
|
||||
MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
|
||||
OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord, PublishAgentRequest,
|
||||
PublishRequest, PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind,
|
||||
SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
|
||||
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord,
|
||||
SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket,
|
||||
UsageOperationBreakdown, UsageQuery, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
|
||||
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
|
||||
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
|
||||
};
|
||||
pub use postgres::{PostgresPoolConfig, PostgresPoolConfigError, PostgresRegistry};
|
||||
|
||||
@@ -451,6 +451,25 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists import_jobs (
|
||||
id text primary key,
|
||||
workspace_id text not null references workspaces(id) on delete cascade,
|
||||
kind text not null,
|
||||
source_format text not null,
|
||||
source_version text null,
|
||||
status text not null,
|
||||
preview_payload jsonb not null,
|
||||
created_operation_ids jsonb not null default '[]'::jsonb,
|
||||
error_text text null,
|
||||
created_at timestamptz not null,
|
||||
expires_at timestamptz not null,
|
||||
finished_at timestamptz null
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
query(
|
||||
"create table if not exists agents (
|
||||
id text primary key,
|
||||
|
||||
@@ -41,6 +41,7 @@ macro_rules! define_registry_id {
|
||||
}
|
||||
|
||||
define_registry_id!(YamlImportJobId);
|
||||
define_registry_id!(ImportJobId);
|
||||
define_registry_id!(WorkspaceUpstreamId);
|
||||
|
||||
pub type RegistryOperation = Operation<Schema, MappingSet>;
|
||||
@@ -326,6 +327,61 @@ pub struct YamlImportJobCompletion {
|
||||
pub finished_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ImportJobStatus {
|
||||
Pending,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ImportJobKind {
|
||||
OpenApi,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ImportJob {
|
||||
pub id: ImportJobId,
|
||||
pub workspace_id: WorkspaceId,
|
||||
pub kind: ImportJobKind,
|
||||
pub source_format: String,
|
||||
pub source_version: Option<String>,
|
||||
pub status: ImportJobStatus,
|
||||
pub preview_payload: Value,
|
||||
pub created_operation_ids: Value,
|
||||
pub error_text: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub expires_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub finished_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CreateImportJobRequest<'a> {
|
||||
pub id: &'a ImportJobId,
|
||||
pub workspace_id: &'a WorkspaceId,
|
||||
pub kind: ImportJobKind,
|
||||
pub source_format: &'a str,
|
||||
pub source_version: Option<&'a str>,
|
||||
pub status: ImportJobStatus,
|
||||
pub preview_payload: &'a Value,
|
||||
pub created_at: &'a OffsetDateTime,
|
||||
pub expires_at: &'a OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FinishImportJobRequest<'a> {
|
||||
pub id: &'a ImportJobId,
|
||||
pub status: ImportJobStatus,
|
||||
pub created_operation_ids: &'a Value,
|
||||
pub error_text: Option<&'a str>,
|
||||
pub finished_at: &'a OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CreateInvocationLogRequest<'a> {
|
||||
pub log: &'a InvocationLog,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
use super::*;
|
||||
|
||||
impl PostgresRegistry {
|
||||
pub async fn create_import_job(
|
||||
&self,
|
||||
request: CreateImportJobRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
sqlx::query(
|
||||
"insert into import_jobs (
|
||||
id,
|
||||
workspace_id,
|
||||
kind,
|
||||
source_format,
|
||||
source_version,
|
||||
status,
|
||||
preview_payload,
|
||||
created_operation_ids,
|
||||
error_text,
|
||||
created_at,
|
||||
expires_at,
|
||||
finished_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, '[]'::jsonb, null, $8::timestamptz, $9::timestamptz, null
|
||||
)",
|
||||
)
|
||||
.bind(request.id.as_str())
|
||||
.bind(request.workspace_id.as_str())
|
||||
.bind(serialize_enum_text(&request.kind, "kind")?)
|
||||
.bind(request.source_format)
|
||||
.bind(request.source_version)
|
||||
.bind(serialize_enum_text(&request.status, "status")?)
|
||||
.bind(request.preview_payload)
|
||||
.bind(request.created_at)
|
||||
.bind(request.expires_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_import_job(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
job_id: &ImportJobId,
|
||||
) -> Result<Option<ImportJob>, RegistryError> {
|
||||
let row = sqlx::query(
|
||||
"select
|
||||
id,
|
||||
workspace_id,
|
||||
kind,
|
||||
source_format,
|
||||
source_version,
|
||||
status,
|
||||
preview_payload,
|
||||
created_operation_ids,
|
||||
error_text,
|
||||
created_at,
|
||||
expires_at,
|
||||
finished_at
|
||||
from import_jobs
|
||||
where id = $1 and workspace_id = $2",
|
||||
)
|
||||
.bind(job_id.as_str())
|
||||
.bind(workspace_id.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
|
||||
row.as_ref().map(map_import_job).transpose()
|
||||
}
|
||||
|
||||
pub async fn finish_import_job(
|
||||
&self,
|
||||
request: FinishImportJobRequest<'_>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let result = sqlx::query(
|
||||
"update import_jobs
|
||||
set status = $2,
|
||||
created_operation_ids = $3,
|
||||
error_text = $4,
|
||||
finished_at = $5::timestamptz
|
||||
where id = $1",
|
||||
)
|
||||
.bind(request.id.as_str())
|
||||
.bind(serialize_enum_text(&request.status, "status")?)
|
||||
.bind(request.created_operation_ids)
|
||||
.bind(request.error_text)
|
||||
.bind(request.finished_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(RegistryError::ImportJobNotFound {
|
||||
job_id: request.id.as_str().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_expired_import_jobs(&self) -> Result<u64, RegistryError> {
|
||||
let result = sqlx::query("delete from import_jobs where expires_at < now()")
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ mod agent;
|
||||
mod api_key;
|
||||
mod auth;
|
||||
mod connection;
|
||||
mod import_job;
|
||||
mod observability;
|
||||
mod operation;
|
||||
mod operation_artifact;
|
||||
@@ -28,9 +29,10 @@ use crate::{
|
||||
error::RegistryError,
|
||||
model::{
|
||||
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentDraftVersionRequest,
|
||||
CreateAgentRequest, CreateInvitationRequest, CreateInvocationLogRequest,
|
||||
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
|
||||
CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorMetadata, InvitationRecord,
|
||||
CreateAgentRequest, CreateImportJobRequest, CreateInvitationRequest,
|
||||
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
|
||||
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
|
||||
DescriptorMetadata, FinishImportJobRequest, ImportJob, ImportJobId, InvitationRecord,
|
||||
InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
|
||||
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
|
||||
PlatformApiKeyRecord, PublishAgentRequest, PublishRequest, PublishedAgentTool,
|
||||
@@ -661,6 +663,23 @@ fn map_yaml_import_job(row: &PgRow) -> Result<YamlImportJob, RegistryError> {
|
||||
})
|
||||
}
|
||||
|
||||
fn map_import_job(row: &PgRow) -> Result<ImportJob, RegistryError> {
|
||||
Ok(ImportJob {
|
||||
id: ImportJobId::new(row.try_get::<String, _>("id")?),
|
||||
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
|
||||
kind: deserialize_enum_text(&row.try_get::<String, _>("kind")?, "kind")?,
|
||||
source_format: row.try_get("source_format")?,
|
||||
source_version: row.try_get("source_version")?,
|
||||
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
|
||||
preview_payload: row.try_get("preview_payload")?,
|
||||
created_operation_ids: row.try_get("created_operation_ids")?,
|
||||
error_text: row.try_get("error_text")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
expires_at: row.try_get("expires_at")?,
|
||||
finished_at: row.try_get("finished_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn map_usage_operation_breakdown(row: &PgRow) -> Result<UsageOperationBreakdown, RegistryError> {
|
||||
Ok(UsageOperationBreakdown {
|
||||
operation_id: OperationId::new(row.try_get::<String, _>("operation_id")?),
|
||||
|
||||
@@ -156,6 +156,7 @@ pub(super) fn test_operation(id: &str, version: u32, status: OperationStatus) ->
|
||||
input_sample: Some(json!({ "email": format!("lead-{version}@example.com") })),
|
||||
output_sample: Some(json!({ "id": format!("lead_{version}") })),
|
||||
test_input: Some(json!({ "email": format!("test-v{version}@example.com") })),
|
||||
import_findings: Vec::new(),
|
||||
}),
|
||||
created_at: timestamp("2026-03-25T11:58:00Z"),
|
||||
updated_at: timestamp(&format!("2026-03-25T12:{version:02}:00Z")),
|
||||
|
||||
@@ -16,6 +16,7 @@ Crank превращает REST API endpoint-ы в MCP-инструменты,
|
||||
|
||||
- [Веб-интерфейс](./ui.md)
|
||||
- [REST-инструменты](./protocols/rest.md)
|
||||
- [Импорт OpenAPI](./openapi-import.md)
|
||||
- [Секреты и профили авторизации](./secrets-and-auth.md)
|
||||
- [Журналы и использование](./observability.md)
|
||||
- [Проектирование MCP-инструментов](./tool-design.md)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Импорт OpenAPI
|
||||
|
||||
Crank умеет импортировать REST API из OpenAPI 3.x и Swagger 2.0 документов. Импорт создает черновики MCP-инструментов, которые можно проверить и доработать в мастере операции перед публикацией.
|
||||
|
||||
## Как импортировать
|
||||
|
||||
1. Откройте раздел **Операции**.
|
||||
2. Нажмите **Импорт OpenAPI** рядом с кнопкой **Новая операция**.
|
||||
3. Загрузите `.yaml`, `.yml`, `.json` файл или вставьте текст спецификации.
|
||||
4. Нажмите **Разобрать документ**.
|
||||
5. Проверьте preview: для каждого метода показываются поля `Path`, `Query`, `Header`, `Body` и поля ответа.
|
||||
6. Выберите нужные группы и методы.
|
||||
7. Проверьте `Base URL`. Если в документе нет `servers`, укажите URL вручную.
|
||||
8. Выберите поведение при конфликте имени:
|
||||
- **Создать копию с новым именем** — Crank добавит суффикс `_2`, `_3` и так далее.
|
||||
- **Пропустить** — существующие операции не будут изменены.
|
||||
9. Нажмите **Создать черновики**.
|
||||
|
||||
## Что создается
|
||||
|
||||
Для каждого выбранного метода Crank создает отдельную операцию в статусе черновика:
|
||||
|
||||
- имя инструмента берется из `operationId`, а если его нет — строится из HTTP-метода и пути;
|
||||
- отображаемое имя берется из `summary`;
|
||||
- описание инструмента берется из `description`;
|
||||
- группа берется из первого `tag`;
|
||||
- query/path/header параметры становятся входными параметрами MCP-инструмента;
|
||||
- JSON `requestBody` с объектной схемой раскладывается в отдельные входные поля;
|
||||
- схема успешного ответа берется из `200`, `201`, `202` или `default`.
|
||||
|
||||
После импорта откройте созданный черновик в мастере операции. На шаге **Связи полей** Crank покажет уже созданный маппинг в визуальном виде:
|
||||
|
||||
- `Path` — параметры из пути, например `/users/{id}`;
|
||||
- `Query` — query-параметры URL;
|
||||
- `Header` — заголовки запроса;
|
||||
- `Body` — поля JSON-тела.
|
||||
|
||||
YAML-маппинг остается доступен в блоке **Дополнительно**, но для обычной настройки его редактировать не нужно.
|
||||
|
||||
## Рекомендации
|
||||
|
||||
После разбора Crank показывает предупреждения, если в спецификации не хватает данных для качественного MCP-инструмента:
|
||||
|
||||
- нет `operationId`;
|
||||
- нет `summary` или `description`;
|
||||
- нет схемы успешного ответа;
|
||||
- нет `servers`;
|
||||
- слишком много входных параметров;
|
||||
- используется тело запроса без JSON-схемы.
|
||||
|
||||
Эти предупреждения не блокируют импорт, но перед публикацией лучше открыть созданную операцию и проверить:
|
||||
|
||||
- описание инструмента;
|
||||
- входные поля и их обязательность;
|
||||
- связи `Path / Query / Header / Body`;
|
||||
- пример входных данных для теста;
|
||||
- поля ответа, которые увидит MCP клиент.
|
||||
|
||||
## Ограничения Community
|
||||
|
||||
В Community импортируются только REST/HTTP методы: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`.
|
||||
|
||||
GraphQL, gRPC, WSDL, SOAP, WebSocket и streaming-протоколы не импортируются в Community.
|
||||
@@ -55,11 +55,11 @@ REST operation в системе описывается следующими о
|
||||
2. Выбирает HTTP method.
|
||||
3. Указывает `path_template`.
|
||||
4. При необходимости загружает пример входного и выходного `JSON`.
|
||||
5. Система строит черновую схему и стартовый mapping.
|
||||
5. Система строит черновую схему и стартовые связи полей.
|
||||
6. Описывает или уточняет входные MCP-параметры.
|
||||
7. Сопоставляет параметры с `path`, `query`, `headers` и `body`.
|
||||
7. Сопоставляет параметры с `Path`, `Query`, `Header` и `Body` в визуальном конструкторе.
|
||||
8. Указывает, откуда извлекать полезные данные в ответе.
|
||||
9. При необходимости уточняет mapping через `JSONPath`.
|
||||
9. При необходимости открывает блок **Дополнительно** и уточняет YAML/JSONPath вручную.
|
||||
10. Запускает тест.
|
||||
11. Публикует operation как MCP tool.
|
||||
|
||||
@@ -80,7 +80,7 @@ Output mapping должен поддерживать:
|
||||
- извлечение вложенных полей
|
||||
- нормализацию отсутствующих значений
|
||||
|
||||
`JSONPath` является основным способом адресации конкретных параметров при работе со вложенными объектами и массивами.
|
||||
В интерфейсе пользователь работает с таблицей связей. `JSONPath` остается внутренним форматом и расширенным режимом для сложных случаев.
|
||||
|
||||
## 7. Поведение runtime
|
||||
|
||||
|
||||
+28
-1
@@ -19,6 +19,34 @@
|
||||
|
||||
Черновик можно редактировать и тестировать. MCP-клиенты видят только опубликованные операции, которые привязаны к опубликованному агенту.
|
||||
|
||||
### Создание операции
|
||||
|
||||
В мастере операции основной сценарий такой:
|
||||
|
||||
1. Выбрать REST.
|
||||
2. Выбрать upstream или добавить новый `Base URL`.
|
||||
3. Указать HTTP-метод и путь endpoint-а.
|
||||
4. Описать инструмент и его входную/выходную схему.
|
||||
5. Загрузить или вставить JSON-пример запроса и ответа.
|
||||
6. Связать поля инструмента с `Path`, `Query`, `Header` и `Body`.
|
||||
7. Выбрать поля ответа API, которые вернет MCP-инструмент.
|
||||
8. Запустить тест.
|
||||
9. Сохранить и опубликовать операцию.
|
||||
|
||||
Для обычной работы достаточно визуального конструктора связей. YAML/JSONPath открыт в блоке **Дополнительно** для сложных случаев: вложенные поля, ручная правка, перенос готовой конфигурации.
|
||||
|
||||
Если пример ответа содержит массив, визуальное дерево показывает поля первого элемента как `items[0].name`. Это удобно, когда агенту нужно одно конкретное поле. Если агенту нужен весь список, используйте блок **Дополнительно** и верните массив целиком.
|
||||
|
||||
### Импорт OpenAPI
|
||||
|
||||
Кнопка **Импорт OpenAPI** создает черновики операций из OpenAPI/Swagger. После импорта откройте черновик в мастере и проверьте:
|
||||
|
||||
- описание инструмента;
|
||||
- входные поля;
|
||||
- связи `Path / Query / Header / Body`;
|
||||
- пример тестового запроса;
|
||||
- поля ответа, которые попадут в результат инструмента.
|
||||
|
||||
## Агенты
|
||||
|
||||
Агент - это отдельный MCP endpoint с выбранным набором инструментов.
|
||||
@@ -51,4 +79,3 @@ API-ключ выдается на конкретного агента. Ключ
|
||||
Раздел **Логи** показывает вызовы операций, ошибки маппинга, ошибки REST API и успешные ответы.
|
||||
|
||||
Раздел **Использование** показывает количество вызовов, ошибки и задержки по операциям.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user