Add OpenAPI import backend

This commit is contained in:
github-ops
2026-06-23 21:03:03 +00:00
parent 87cdb95b80
commit a8aa3248c9
34 changed files with 2569 additions and 27 deletions
+1
View File
@@ -16,6 +16,7 @@ axum-extra.workspace = true
base64.workspace = true
crank-community-auth = { path = "../../crates/crank-community-auth" }
crank-core = { path = "../../crates/crank-core" }
crank-import = { path = "../../crates/crank-import" }
crank-mapping = { path = "../../crates/crank-mapping" }
crank-registry = { path = "../../crates/crank-registry" }
crank-runtime = { path = "../../crates/crank-runtime" }
+6
View File
@@ -18,6 +18,7 @@ use crate::{
auth::{change_password, get_profile, get_session, login, logout, update_profile},
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
capabilities::get_capabilities,
imports::{create_openapi_import, preview_openapi_import},
observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs},
operations::{
analyze_operation_quality, archive_operation, create_operation, create_version,
@@ -35,6 +36,11 @@ use crate::{
pub fn build_app(state: AppState) -> Router {
let workspace_router = Router::new()
.route("/operations", get(list_operations).post(create_operation))
.route("/imports/openapi/preview", post(preview_openapi_import))
.route(
"/imports/openapi/{job_id}/create",
post(create_openapi_import),
)
.route(
"/operations/analyze-quality",
post(analyze_operation_quality),
+46
View File
@@ -309,6 +309,48 @@ pub struct YamlOperationDocument {
pub operation: RegistryOperation,
}
#[derive(Clone, Debug, Deserialize)]
pub struct OpenApiImportPreviewPayload {
pub document: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct OpenApiImportPreviewResponse {
pub job_id: String,
pub expires_at: String,
pub preview: crank_import::rest::ImportPreview,
}
#[derive(Clone, Debug, Deserialize)]
pub struct OpenApiImportCreatePayload {
pub selected_operation_keys: Vec<String>,
#[serde(default)]
pub server_url: Option<String>,
#[serde(default = "default_openapi_conflict_mode")]
pub conflict_mode: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct OpenApiImportCreateResponse {
pub created: Vec<OpenApiImportCreatedOperation>,
pub skipped: Vec<OpenApiImportSkippedOperation>,
pub findings: Vec<crank_import::rest::ImportFinding>,
}
#[derive(Clone, Debug, Serialize)]
pub struct OpenApiImportCreatedOperation {
pub operation_id: String,
pub name: String,
pub version: u32,
}
#[derive(Clone, Debug, Serialize)]
pub struct OpenApiImportSkippedOperation {
pub operation_key: String,
pub name: String,
pub reason: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct CreatedOperationResponse {
pub operation_id: String,
@@ -433,6 +475,10 @@ fn default_operation_category() -> String {
"general".to_owned()
}
fn default_openapi_conflict_mode() -> String {
"rename".to_owned()
}
fn default_export_mode() -> ExportMode {
ExportMode::Portable
}
+4
View File
@@ -347,6 +347,10 @@ impl From<RegistryError> for ApiError {
format!("yaml import job {job_id} was not found"),
json!({ "job_id": job_id }),
),
RegistryError::ImportJobNotFound { job_id } => Self::not_found_with_context(
format!("import job {job_id} was not found"),
json!({ "job_id": job_id }),
),
RegistryError::Storage(_) | RegistryError::Serialization(_) => {
Self::internal(value.to_string())
}
+1
View File
@@ -3,6 +3,7 @@ pub mod agents;
pub mod auth;
pub mod auth_profiles;
pub mod capabilities;
pub mod imports;
pub mod observability;
pub mod operations;
pub mod secrets;
+51
View File
@@ -0,0 +1,51 @@
use axum::{
Json,
extract::{Path, State},
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
error::ApiError,
service::{OpenApiImportCreatePayload, OpenApiImportPreviewPayload},
state::AppState,
};
#[derive(Deserialize)]
pub struct WorkspacePath {
pub workspace_id: String,
}
#[derive(Deserialize)]
pub struct WorkspaceImportPath {
pub workspace_id: String,
pub job_id: String,
}
pub async fn preview_openapi_import(
Path(path): Path<WorkspacePath>,
State(state): State<AppState>,
Json(payload): Json<OpenApiImportPreviewPayload>,
) -> Result<Json<Value>, ApiError> {
let preview = state
.service
.preview_openapi_import(&path.workspace_id.as_str().into(), payload)
.await?;
Ok(Json(json!(preview)))
}
pub async fn create_openapi_import(
Path(path): Path<WorkspaceImportPath>,
State(state): State<AppState>,
Json(payload): Json<OpenApiImportCreatePayload>,
) -> Result<Json<Value>, ApiError> {
let imported = state
.service
.create_openapi_import(
&path.workspace_id.as_str().into(),
&path.job_id.as_str().into(),
payload,
)
.await?;
Ok(Json(json!(imported)))
}
+1
View File
@@ -27,6 +27,7 @@ mod api_keys;
mod auth;
mod demo;
mod import_export;
mod imports;
mod observability;
mod operation_validation;
mod operations;
+1
View File
@@ -392,6 +392,7 @@ fn demo_rest_operation_payload() -> OperationPayload {
input_sample: Some(input),
output_sample: Some(output),
test_input: Some(demo_rest_input_sample()),
import_findings: Vec::new(),
}),
}
}
+285
View File
@@ -0,0 +1,285 @@
use std::collections::{BTreeMap, BTreeSet};
use crank_core::{
ExecutionConfig, OperationSecurityLevel, Protocol, ToolQualityFinding, ToolQualitySeverity,
WorkspaceId,
};
use crank_import::rest::{
ImportFinding, ImportFindingSeverity, ImportOperationCandidate, operation_draft_from_candidate,
};
use crank_registry::{
CreateImportJobRequest, FinishImportJobRequest, ImportJobId, ImportJobKind, ImportJobStatus,
};
use serde_json::json;
use time::{Duration, OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::{info, instrument};
use crate::{
error::ApiError,
service::{
AdminService, OpenApiImportCreatePayload, OpenApiImportCreateResponse,
OpenApiImportCreatedOperation, OpenApiImportPreviewPayload, OpenApiImportPreviewResponse,
OpenApiImportSkippedOperation, OperationPayload, new_prefixed_id,
},
};
const IMPORT_JOB_TTL_HOURS: i64 = 24;
impl AdminService {
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str()))]
pub async fn preview_openapi_import(
&self,
workspace_id: &WorkspaceId,
payload: OpenApiImportPreviewPayload,
) -> Result<OpenApiImportPreviewResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let _ = self.registry.delete_expired_import_jobs().await;
let preview = crank_import::rest::preview_document(&payload.document)
.map_err(|error| ApiError::validation(error.to_string()))?;
let now = OffsetDateTime::now_utc();
let expires_at = now + Duration::hours(IMPORT_JOB_TTL_HOURS);
let job_id = ImportJobId::new(new_prefixed_id("imp"));
let preview_payload = serde_json::to_value(&preview)
.map_err(|error| ApiError::internal(error.to_string()))?;
self.registry
.create_import_job(CreateImportJobRequest {
id: &job_id,
workspace_id,
kind: ImportJobKind::OpenApi,
source_format: &preview.source.format,
source_version: preview.source.version.as_deref(),
status: ImportJobStatus::Completed,
preview_payload: &preview_payload,
created_at: &now,
expires_at: &expires_at,
})
.await?;
Ok(OpenApiImportPreviewResponse {
job_id: job_id.as_str().to_owned(),
expires_at: expires_at
.format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?,
preview,
})
}
#[instrument(skip(self, payload), fields(workspace_id = %workspace_id.as_str(), job_id = %job_id.as_str()))]
pub async fn create_openapi_import(
&self,
workspace_id: &WorkspaceId,
job_id: &ImportJobId,
payload: OpenApiImportCreatePayload,
) -> Result<OpenApiImportCreateResponse, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let _ = self.registry.delete_expired_import_jobs().await;
if !matches!(payload.conflict_mode.as_str(), "skip" | "rename") {
return Err(ApiError::validation(
"unsupported conflict_mode; supported values are skip and rename",
));
}
let job = self
.registry
.get_import_job(workspace_id, job_id)
.await?
.ok_or_else(|| {
ApiError::not_found_with_context(
"import job was not found",
json!({ "job_id": job_id.as_str() }),
)
})?;
if job.expires_at <= OffsetDateTime::now_utc() {
return Err(ApiError::validation("import preview has expired"));
}
if job.kind != ImportJobKind::OpenApi {
return Err(ApiError::validation("import job kind is not openapi"));
}
let preview: crank_import::rest::ImportPreview =
serde_json::from_value(job.preview_payload.clone())
.map_err(|error| ApiError::internal(error.to_string()))?;
let selected = payload
.selected_operation_keys
.iter()
.cloned()
.collect::<BTreeSet<_>>();
if selected.is_empty() {
return Err(ApiError::validation(
"selected_operation_keys must contain at least one operation",
));
}
let mut candidates = BTreeMap::new();
for group in &preview.groups {
for operation in &group.operations {
candidates.insert(operation.key.clone(), operation);
}
}
let mut created = Vec::new();
let mut skipped = Vec::new();
let mut findings = Vec::new();
let mut created_ids = Vec::new();
for operation_key in selected {
let Some(candidate) = candidates.get(&operation_key) else {
skipped.push(OpenApiImportSkippedOperation {
operation_key,
name: String::new(),
reason: "operation was not found in import preview".to_owned(),
});
continue;
};
let mut draft =
operation_draft_from_candidate(candidate, payload.server_url.as_deref());
attach_import_findings(&mut draft, candidate);
if let Some(existing_name) = self
.find_operation_by_name(workspace_id, &draft.name)
.await?
.map(|operation| operation.name)
{
if payload.conflict_mode == "skip" {
skipped.push(OpenApiImportSkippedOperation {
operation_key: candidate.key.clone(),
name: draft.name.clone(),
reason: "operation with this name already exists".to_owned(),
});
findings.push(ImportFinding {
code: "operation_name_conflict".to_owned(),
severity: ImportFindingSeverity::Warning,
message: format!(
"Операция {} уже существует и была пропущена.",
draft.name
),
operation_key: Some(candidate.key.clone()),
});
continue;
}
let renamed = self
.next_available_operation_name(workspace_id, &draft.name)
.await?;
findings.push(ImportFinding {
code: "operation_name_renamed".to_owned(),
severity: ImportFindingSeverity::Info,
message: format!(
"Операция {existing_name} уже существует, новый черновик создан как {renamed}."
),
operation_key: Some(candidate.key.clone()),
});
draft.name = renamed;
}
let payload = OperationPayload {
name: draft.name.clone(),
display_name: draft.display_name.clone(),
category: draft.category,
protocol: Protocol::Rest,
security_level: OperationSecurityLevel::Standard,
target: crank_core::Target::Rest(draft.target),
input_schema: draft.input_schema,
output_schema: draft.output_schema,
input_mapping: draft.input_mapping,
output_mapping: draft.output_mapping,
execution_config: ExecutionConfig {
timeout_ms: 10_000,
retry_policy: None,
response_cache: None,
idempotency: None,
safety: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
},
tool_description: draft.tool_description,
wizard_state: draft.wizard_state,
};
let result = self.create_operation(workspace_id, payload).await?;
created_ids.push(result.operation_id.clone());
created.push(OpenApiImportCreatedOperation {
operation_id: result.operation_id,
name: draft.name,
version: result.version,
});
}
let finished_at = OffsetDateTime::now_utc();
self.registry
.finish_import_job(FinishImportJobRequest {
id: job_id,
status: ImportJobStatus::Completed,
created_operation_ids: &json!(created_ids),
error_text: None,
finished_at: &finished_at,
})
.await?;
info!(
created = created.len(),
skipped = skipped.len(),
"openapi import created drafts"
);
Ok(OpenApiImportCreateResponse {
created,
skipped,
findings,
})
}
async fn next_available_operation_name(
&self,
workspace_id: &WorkspaceId,
base_name: &str,
) -> Result<String, ApiError> {
for index in 2.. {
let candidate = format!("{base_name}_{index}");
if self
.find_operation_by_name(workspace_id, &candidate)
.await?
.is_none()
{
return Ok(candidate);
}
}
unreachable!()
}
}
fn attach_import_findings(
draft: &mut crank_import::rest::RestImportCandidate,
candidate: &ImportOperationCandidate,
) {
let findings = candidate
.findings
.iter()
.map(tool_quality_finding_from_import)
.collect::<Vec<_>>();
if findings.is_empty() {
return;
}
let mut wizard_state = draft.wizard_state.take().unwrap_or_default();
wizard_state.import_findings = findings;
draft.wizard_state = Some(wizard_state);
}
fn tool_quality_finding_from_import(finding: &ImportFinding) -> ToolQualityFinding {
ToolQualityFinding {
severity: match finding.severity {
ImportFindingSeverity::Info => ToolQualitySeverity::Info,
ImportFindingSeverity::Warning => ToolQualitySeverity::Warning,
ImportFindingSeverity::Error => ToolQualitySeverity::Error,
},
code: format!("openapi_import.{}", finding.code),
message: finding.message.clone(),
suggested_action: Some(
"Откройте черновик в мастере и уточните описание, схемы или маппинг перед публикацией."
.to_owned(),
),
field_path: finding.operation_key.clone(),
}
}
+1
View File
@@ -2,6 +2,7 @@ mod integration {
mod auth_rate_limit;
mod common;
mod community_access_usage;
mod openapi_import;
mod operations_agents;
mod secrets_import_auth;
}
@@ -0,0 +1,149 @@
use admin_api::service::{OpenApiImportCreatePayload, OpenApiImportPreviewPayload};
use crank_core::WorkspaceId;
use serial_test::serial;
use super::common::{
test_auth_settings, test_registry, test_secret_crypto, test_service, test_storage_root,
};
const OPENAPI3: &str = r#"
openapi: 3.0.3
info:
title: Frankfurter API
servers:
- url: https://api.frankfurter.dev
paths:
/v2/latest:
get:
operationId: latestRates
summary: Получить последние курсы валют
description: Курсы.
tags: [currency]
parameters:
- name: base
in: query
required: true
schema: { type: string }
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
base: { type: string }
rates:
type: object
"#;
#[tokio::test]
#[serial]
async fn previews_openapi_and_creates_draft_operations() {
let registry = test_registry().await;
let service = test_service(
registry,
test_storage_root("openapi_import"),
test_auth_settings(),
test_secret_crypto(),
);
let workspace_id = WorkspaceId::new("ws_default");
let preview = service
.preview_openapi_import(
&workspace_id,
OpenApiImportPreviewPayload {
document: OPENAPI3.to_owned(),
},
)
.await
.unwrap();
assert_eq!(preview.preview.groups.len(), 1);
assert_eq!(
preview.preview.groups[0].operations[0].suggested_name,
"latest_rates"
);
let created = service
.create_openapi_import(
&workspace_id,
&preview.job_id.as_str().into(),
OpenApiImportCreatePayload {
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
server_url: Some("https://api.frankfurter.dev".to_owned()),
conflict_mode: "skip".to_owned(),
},
)
.await
.unwrap();
assert_eq!(created.created.len(), 1);
assert_eq!(created.created[0].name, "latest_rates");
assert!(created.skipped.is_empty());
let operations = service.list_operations(&workspace_id).await.unwrap();
assert!(
operations
.iter()
.any(|operation| operation.name == "latest_rates")
);
let imported = operations
.iter()
.find(|operation| operation.name == "latest_rates")
.unwrap();
let version = service
.get_operation_version(
&workspace_id,
&imported.id.as_str().into(),
imported.current_draft_version,
)
.await
.unwrap();
let import_findings = &version
.snapshot
.wizard_state
.as_ref()
.unwrap()
.import_findings;
assert!(
import_findings
.iter()
.any(|finding| finding.code == "openapi_import.weak_tool_description")
);
let skipped = service
.create_openapi_import(
&workspace_id,
&preview.job_id.as_str().into(),
OpenApiImportCreatePayload {
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
server_url: Some("https://api.frankfurter.dev".to_owned()),
conflict_mode: "skip".to_owned(),
},
)
.await
.unwrap();
assert!(skipped.created.is_empty());
assert_eq!(skipped.skipped.len(), 1);
assert_eq!(skipped.skipped[0].name, "latest_rates");
assert_eq!(skipped.findings[0].code, "operation_name_conflict");
let renamed = service
.create_openapi_import(
&workspace_id,
&preview.job_id.as_str().into(),
OpenApiImportCreatePayload {
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
server_url: Some("https://api.frankfurter.dev".to_owned()),
conflict_mode: "rename".to_owned(),
},
)
.await
.unwrap();
assert_eq!(renamed.created.len(), 1);
assert_eq!(renamed.created[0].name, "latest_rates_2");
assert_eq!(renamed.findings[0].code, "operation_name_renamed");
}