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