api: add structured context for service errors

This commit is contained in:
a.tolmachev
2026-04-19 21:45:26 +00:00
parent 37bc3c4e2b
commit ba10bf805d
3 changed files with 197 additions and 24 deletions
+115 -5
View File
@@ -221,18 +221,19 @@ mod tests {
use axum::{Json, Router, routing::post};
use crank_adapter_grpc::test_support as grpc_test_support;
use crank_core::{
DescriptorId, ExecutionConfig, ExecutionMode, GraphqlOperationType, GraphqlTarget,
GrpcTarget, HttpMethod, MembershipRole, Protocol, RestTarget, SecretKind, SoapBindingStyle,
SoapOperationMetadata, SoapTarget, SoapVersion, Target, ToolDescription, TransportBehavior,
WorkspaceId,
AsyncJobHandle, AsyncJobId, DescriptorId, ExecutionConfig, ExecutionMode,
GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod, JobStatus, MembershipRole,
OperationId, Protocol, RestTarget, SecretKind, SoapBindingStyle, SoapOperationMetadata,
SoapTarget, SoapVersion, Target, ToolDescription, TransportBehavior, WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::PostgresRegistry;
use crank_registry::{CreateAsyncJobRequest, PostgresRegistry};
use crank_runtime::SecretCrypto;
use crank_schema::{Schema, SchemaKind};
use serde_json::{Value, json};
use serial_test::serial;
use sqlx::{Connection, Executor, PgConnection};
use time::OffsetDateTime;
use tokio::net::TcpListener;
use crate::{
@@ -1613,6 +1614,69 @@ mod tests {
assert_eq!(result["id"], "lead_123");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn returns_structured_context_for_pending_async_job_result() {
let registry = test_registry().await;
let storage_root = test_storage_root("async_job_pending_result");
let upstream_base_url = spawn_upstream_server().await;
let job_id = AsyncJobId::new("job_pending_result".to_owned());
let base_url = spawn_admin_api(build_test_app(registry.clone(), storage_root)).await;
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
.json(&test_rest_async_job_operation_payload(
&upstream_base_url,
"crm_async_job_pending_result",
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_id = OperationId::new(created["operation_id"].as_str().unwrap().to_owned());
let now = OffsetDateTime::now_utc();
registry
.create_async_job(CreateAsyncJobRequest {
job: &AsyncJobHandle {
id: job_id.clone(),
workspace_id: WorkspaceId::new(DEFAULT_WORKSPACE_ID),
agent_id: None,
operation_id,
status: JobStatus::Running,
progress: json!({ "pct": 25 }),
result: None,
error: None,
expires_at: Some(now + time::Duration::minutes(5)),
created_at: now,
updated_at: now,
finished_at: None,
},
})
.await
.unwrap();
let response = client
.get(format!("{base_url}/async-jobs/{}/result", job_id.as_str()))
.send()
.await
.unwrap();
let status = response.status();
let body = response.json::<Value>().await.unwrap();
assert_eq!(status, reqwest::StatusCode::CONFLICT);
assert_eq!(body["error"]["code"], "conflict");
assert_eq!(body["error"]["message"], "async job result is not ready");
assert_eq!(
body["error"]["context"],
json!({
"job_id": job_id.as_str(),
"status": "running"
})
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn uploads_wsdl_and_tests_soap_operation() {
@@ -1684,6 +1748,52 @@ mod tests {
assert_eq!(test_run["response_preview"]["id"], "lead_123");
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn returns_structured_context_when_wsdl_is_missing() {
let registry = test_registry().await;
let storage_root = test_storage_root("soap_missing_wsdl");
let endpoint = spawn_soap_server().await;
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
let client = authorized_client(&base_url).await;
let created = client
.post(format!("{base_url}/operations"))
.json(&test_soap_operation_payload(
&endpoint,
"crm_create_lead_soap_missing_wsdl",
))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
let operation_id = created["operation_id"].as_str().unwrap().to_owned();
let response = client
.get(format!(
"{base_url}/operations/{operation_id}/soap/services"
))
.send()
.await
.unwrap();
let status = response.status();
let body = response.json::<Value>().await.unwrap();
assert_eq!(status, reqwest::StatusCode::NOT_FOUND);
assert_eq!(body["error"]["code"], "not_found");
assert_eq!(body["error"]["message"], "wsdl was not uploaded");
assert_eq!(
body["error"]["context"],
json!({
"operation_id": operation_id,
"version": 1,
"descriptor_kind": "wsdl_upload"
})
);
}
#[tokio::test(flavor = "multi_thread")]
#[serial]
async fn manages_auth_profiles_and_yaml_upsert() {
+3 -3
View File
@@ -90,21 +90,21 @@ impl ApiError {
}
}
fn validation_with_context(message: impl Into<String>, context: Value) -> Self {
pub(crate) fn validation_with_context(message: impl Into<String>, context: Value) -> Self {
Self::Validation {
message: message.into(),
context: Some(context),
}
}
fn not_found_with_context(message: impl Into<String>, context: Value) -> Self {
pub(crate) fn not_found_with_context(message: impl Into<String>, context: Value) -> Self {
Self::NotFound {
message: message.into(),
context: Some(context),
}
}
fn conflict_with_context(message: impl Into<String>, context: Value) -> Self {
pub(crate) fn conflict_with_context(message: impl Into<String>, context: Value) -> Self {
Self::Conflict {
message: message.into(),
context: Some(context),
+79 -16
View File
@@ -1611,7 +1611,10 @@ impl AdminService {
) -> Result<AsyncJobDetailView, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let job = self.registry.get_async_job(job_id).await?.ok_or_else(|| {
ApiError::not_found(format!("async job {} was not found", job_id.as_str()))
ApiError::not_found_with_context(
format!("async job {} was not found", job_id.as_str()),
json!({ "job_id": job_id.as_str() }),
)
})?;
ensure_async_job_workspace(&job, workspace_id)?;
@@ -1626,14 +1629,20 @@ impl AdminService {
) -> Result<AsyncJobDetailView, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let job = self.registry.get_async_job(job_id).await?.ok_or_else(|| {
ApiError::not_found(format!("async job {} was not found", job_id.as_str()))
ApiError::not_found_with_context(
format!("async job {} was not found", job_id.as_str()),
json!({ "job_id": job_id.as_str() }),
)
})?;
ensure_async_job_workspace(&job, workspace_id)?;
self.registry
.cancel_async_job(job_id, &OffsetDateTime::now_utc())
.await?;
let updated = self.registry.get_async_job(job_id).await?.ok_or_else(|| {
ApiError::not_found(format!("async job {} was not found", job_id.as_str()))
ApiError::not_found_with_context(
format!("async job {} was not found", job_id.as_str()),
json!({ "job_id": job_id.as_str() }),
)
})?;
Ok(async_job_detail_view(updated))
@@ -1647,15 +1656,44 @@ impl AdminService {
) -> Result<Value, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let job = self.registry.get_async_job(job_id).await?.ok_or_else(|| {
ApiError::not_found(format!("async job {} was not found", job_id.as_str()))
ApiError::not_found_with_context(
format!("async job {} was not found", job_id.as_str()),
json!({ "job_id": job_id.as_str() }),
)
})?;
ensure_async_job_workspace(&job, workspace_id)?;
match job.status {
JobStatus::Completed => Ok(job.result.unwrap_or(Value::Null)),
JobStatus::Failed => Err(ApiError::conflict("async job failed")),
JobStatus::Cancelled => Err(ApiError::conflict("async job was cancelled")),
_ => Err(ApiError::conflict("async job result is not ready")),
JobStatus::Failed => Err(ApiError::conflict_with_context(
"async job failed",
json!({
"job_id": job_id.as_str(),
"status": "failed",
"error": job.error,
}),
)),
JobStatus::Cancelled => Err(ApiError::conflict_with_context(
"async job was cancelled",
json!({
"job_id": job_id.as_str(),
"status": "cancelled",
}),
)),
_ => Err(ApiError::conflict_with_context(
"async job result is not ready",
json!({
"job_id": job_id.as_str(),
"status": match job.status {
JobStatus::Created => "created",
JobStatus::Running => "running",
JobStatus::Completed => "completed",
JobStatus::Failed => "failed",
JobStatus::Cancelled => "cancelled",
JobStatus::Expired => "expired",
},
}),
)),
}
}
@@ -3055,7 +3093,16 @@ impl AdminService {
.find(|descriptor| {
descriptor.descriptor_kind == crank_registry::DescriptorKind::DescriptorSet
})
.ok_or_else(|| ApiError::not_found("descriptor set was not uploaded"))?;
.ok_or_else(|| {
ApiError::not_found_with_context(
"descriptor set was not uploaded",
json!({
"operation_id": operation_id.as_str(),
"version": version,
"descriptor_kind": "descriptor_set",
}),
)
})?;
let bytes = self.storage.read_bytes(&descriptor.storage_ref).await?;
let services = services_from_descriptor_set_bytes(&bytes)
.map_err(|error| ApiError::validation(error.to_string()))?;
@@ -3207,10 +3254,26 @@ impl AdminService {
.find(|descriptor| {
descriptor.descriptor_kind == crank_registry::DescriptorKind::WsdlUpload
})
.ok_or_else(|| ApiError::not_found("wsdl was not uploaded"))?;
let package_index = descriptor
.package_index
.ok_or_else(|| ApiError::not_found("wsdl inspection metadata is not available"))?;
.ok_or_else(|| {
ApiError::not_found_with_context(
"wsdl was not uploaded",
json!({
"operation_id": operation_id.as_str(),
"version": version,
"descriptor_kind": "wsdl_upload",
}),
)
})?;
let package_index = descriptor.package_index.ok_or_else(|| {
ApiError::not_found_with_context(
"wsdl inspection metadata is not available",
json!({
"operation_id": operation_id.as_str(),
"version": version,
"descriptor_kind": "wsdl_upload",
}),
)
})?;
serde_json::from_value(package_index).map_err(|error| ApiError::internal(error.to_string()))
}
@@ -3225,10 +3288,10 @@ impl AdminService {
.get_auth_profile(workspace_id, auth_profile_id)
.await?
.ok_or_else(|| {
ApiError::not_found(format!(
"auth profile {} was not found",
auth_profile_id.as_str()
))
ApiError::not_found_with_context(
format!("auth profile {} was not found", auth_profile_id.as_str()),
json!({ "auth_profile_id": auth_profile_id.as_str() }),
)
})
}