Compare commits

...

4 Commits

Author SHA1 Message Date
github-ops 42dd796927 Document OpenAPI import workflow
CI / Rust Checks (push) Failing after 25s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Deployment Manifests (push) Has been skipped
Deploy / deploy (push) Failing after 2m39s
2026-06-23 21:03:35 +00:00
github-ops 04f63e690c Improve wizard mapping and quality guidance 2026-06-23 21:03:25 +00:00
github-ops 052b9356ec Add OpenAPI import UI 2026-06-23 21:03:14 +00:00
github-ops a8aa3248c9 Add OpenAPI import backend 2026-06-23 21:03:03 +00:00
51 changed files with 5100 additions and 61 deletions
Generated
+14
View File
@@ -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"
+1
View File
@@ -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",
+1
View File
@@ -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" }
+6
View File
@@ -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),
+46
View File
@@ -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
}
+4
View File
@@ -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())
}
+1
View File
@@ -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;
+51
View File
@@ -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)))
}
+1
View File
@@ -27,6 +27,7 @@ mod api_keys;
mod auth;
mod demo;
mod import_export;
mod imports;
mod observability;
mod operation_validation;
mod operations;
+1
View File
@@ -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(),
}),
}
}
+285
View File
@@ -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(),
}
}
+1
View File
@@ -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");
}
+449
View File
@@ -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;
}
}
+141
View File
@@ -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;
+63 -17
View File
@@ -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
View File
@@ -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>
+8
View File
@@ -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');
},
+9
View File
@@ -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();
},
+554
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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,
};
}());
+95
View File
@@ -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'));
}
+725
View File
@@ -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
View File
@@ -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;
+2
View File
@@ -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',
+107 -1
View File
@@ -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'));
});
+101
View File
@@ -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' },
},
]);
});
+3
View File
@@ -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)]
+16
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
pub mod rest;
+79
View File
@@ -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 }
}
+17
View File
@@ -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;
+123
View File
@@ -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,
}
+62
View File
@@ -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}")
}
+111
View File
@@ -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)
}
+228
View File
@@ -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,
}
}
+242
View File
@@ -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,
}
}
+203
View File
@@ -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()
}
+234
View File
@@ -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,
}
}
+243
View File
@@ -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"));
}
}
+2
View File
@@ -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}")]
+26 -24
View File
@@ -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};
+19
View File
@@ -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,
+56
View File
@@ -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())
}
}
+22 -3
View File
@@ -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")),
+1
View File
@@ -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)
+63
View File
@@ -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.
+4 -4
View File
@@ -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
View File
@@ -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 и успешные ответы.
Раздел **Использование** показывает количество вызовов, ошибки и задержки по операциям.