Add OpenAPI import backend
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user