use axum::{ Json, http::StatusCode, response::{IntoResponse, Response}, }; use crank_mapping::MappingError; use crank_registry::RegistryError; use crank_runtime::RuntimeError; use crank_schema::SchemaError; use serde_json::{Value, json}; use thiserror::Error; use tracing::{error, warn}; use crate::storage::StorageError; #[derive(Debug, Error)] pub enum ApiError { #[error("{message}")] Unauthorized { message: String }, #[error("{message}")] Forbidden { message: String }, #[error("{message}")] Validation { message: String }, #[error("{message}")] NotFound { message: String }, #[error("{message}")] Conflict { message: String }, #[error("{message}")] Internal { message: String }, } impl ApiError { pub fn unauthorized(message: impl Into) -> Self { Self::Unauthorized { message: message.into(), } } pub fn forbidden(message: impl Into) -> Self { Self::Forbidden { message: message.into(), } } pub fn validation(message: impl Into) -> Self { Self::Validation { message: message.into(), } } pub fn not_found(message: impl Into) -> Self { Self::NotFound { message: message.into(), } } pub fn conflict(message: impl Into) -> Self { Self::Conflict { message: message.into(), } } pub fn internal(message: impl Into) -> Self { Self::Internal { message: message.into(), } } fn status_code(&self) -> StatusCode { match self { Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED, Self::Forbidden { .. } => StatusCode::FORBIDDEN, Self::Validation { .. } => StatusCode::BAD_REQUEST, Self::NotFound { .. } => StatusCode::NOT_FOUND, Self::Conflict { .. } => StatusCode::CONFLICT, Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR, } } fn code(&self) -> &'static str { match self { Self::Unauthorized { .. } => "unauthorized", Self::Forbidden { .. } => "forbidden", Self::Validation { .. } => "validation_error", Self::NotFound { .. } => "not_found", Self::Conflict { .. } => "conflict", Self::Internal { .. } => "internal_error", } } } impl IntoResponse for ApiError { fn into_response(self) -> Response { match &self { Self::Internal { message } => { error!(error_code = self.code(), error_message = %message) } Self::Unauthorized { message } | Self::Forbidden { message } | Self::Validation { message } | Self::NotFound { message } | Self::Conflict { message } => { warn!(error_code = self.code(), error_message = %message) } } let body = Json(json!({ "error": { "code": self.code(), "message": self.to_string(), } })); (self.status_code(), body).into_response() } } impl From for ApiError { fn from(value: RegistryError) -> Self { match value { RegistryError::WorkspaceNotFound { workspace_id } => { Self::not_found(format!("workspace {workspace_id} was not found")) } RegistryError::UserNotFound { user_id } => { Self::not_found(format!("user {user_id} was not found")) } RegistryError::MembershipNotFound { workspace_id, user_id, } => Self::not_found(format!( "membership for user {user_id} in workspace {workspace_id} was not found" )), RegistryError::AgentNotFound { agent_id } => { Self::not_found(format!("agent {agent_id} was not found")) } RegistryError::InvitationNotFound { invitation_id } => { Self::not_found(format!("invitation {invitation_id} was not found")) } RegistryError::PlatformApiKeyNotFound { key_id } => { Self::not_found(format!("platform api key {key_id} was not found")) } RegistryError::SecretNotFound { secret_id } => { Self::not_found(format!("secret {secret_id} was not found")) } RegistryError::StreamSessionNotFound { session_id } => { Self::not_found(format!("stream session {session_id} was not found")) } RegistryError::AsyncJobNotFound { job_id } => { Self::not_found(format!("async job {job_id} was not found")) } RegistryError::InvocationLogNotFound { log_id } => { Self::not_found(format!("invocation log {log_id} was not found")) } RegistryError::PublishedAgentNotFound { workspace_slug, agent_slug, } => Self::not_found(format!( "published agent {workspace_slug}/{agent_slug} was not found" )), RegistryError::OperationNotFound { operation_id } => { Self::not_found(format!("operation {operation_id} was not found")) } RegistryError::OperationVersionNotFound { operation_id, version, } => Self::not_found(format!( "operation version {version} for {operation_id} was not found" )), RegistryError::OperationHasPublishedAgentBindings { operation_id } => { Self::conflict(format!( "operation {operation_id} cannot be deleted while it is bound to a published agent" )) } RegistryError::AuthProfileNotFound { auth_profile_id } => { Self::not_found(format!("auth profile {auth_profile_id} was not found")) } RegistryError::OperationAlreadyExists { operation_id } => { Self::conflict(format!("operation {operation_id} already exists")) } RegistryError::WorkspaceSlugAlreadyExists { slug } => { Self::conflict(format!("workspace with slug {slug} already exists")) } RegistryError::SecretNameAlreadyExists { workspace_id, name } => Self::conflict( format!("secret with name {name} already exists in workspace {workspace_id}"), ), RegistryError::SecretReferencedByAuthProfile { secret_id, auth_profile_id, } => Self::conflict(format!( "secret {secret_id} is referenced by auth profile {auth_profile_id}" )), RegistryError::InvalidStreamSessionTransition { .. } | RegistryError::InvalidAsyncJobTransition { .. } => Self::conflict(value.to_string()), RegistryError::UserEmailAlreadyExists { email } => { Self::conflict(format!("user with email {email} already exists")) } RegistryError::InvalidInitialVersion { .. } | RegistryError::InvalidVersionSequence { .. } | RegistryError::ImmutableOperationFieldChanged { .. } | RegistryError::InvalidEnumRepresentation { .. } | RegistryError::InvalidNumericValue { .. } | RegistryError::YamlImportJobNotFound { .. } => Self::validation(value.to_string()), RegistryError::Storage(_) | RegistryError::Serialization(_) => { Self::internal(value.to_string()) } } } } impl From for ApiError { fn from(value: MappingError) -> Self { Self::validation(value.to_string()) } } impl From for ApiError { fn from(value: SchemaError) -> Self { Self::validation(value.to_string()) } } impl From for ApiError { fn from(value: StorageError) -> Self { match value { StorageError::InvalidStorageRef { details } => Self::validation(details), StorageError::Io(_) | StorageError::Serialization(_) => { Self::internal(value.to_string()) } } } } pub fn runtime_test_failure(error: &RuntimeError) -> Value { let mut payload = json!({ "code": runtime_test_failure_code(error), "message": error.to_string() }); if let Some(context) = runtime_error_context(error) { payload["context"] = context; } payload } fn runtime_test_failure_code(error: &RuntimeError) -> &'static str { match error { RuntimeError::Schema(_) => "runtime_schema_error", RuntimeError::Mapping(_) => "runtime_mapping_error", RuntimeError::GraphqlAdapter(_) => "runtime_graphql_error", RuntimeError::GrpcAdapter(_) => "runtime_grpc_error", RuntimeError::RestAdapter(_) => "runtime_rest_error", RuntimeError::SoapAdapter(_) => "runtime_soap_error", RuntimeError::WebsocketAdapter(_) => "runtime_websocket_error", RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error", RuntimeError::InvalidPreparedRequest { .. } => "runtime_request_error", RuntimeError::MissingStreamingConfig { .. } => "runtime_streaming_config_error", RuntimeError::UnsupportedExecutionMode { .. } => "runtime_streaming_mode_error", RuntimeError::InvalidStreamingPayload { .. } => "runtime_streaming_payload_error", RuntimeError::MissingAuthProfile { .. } => "runtime_auth_profile_error", RuntimeError::MissingSecret { .. } | RuntimeError::MissingSecretVersion { .. } => { "runtime_secret_error" } RuntimeError::InvalidAuthSecretValue { .. } => "runtime_secret_value_error", RuntimeError::SecretCrypto { .. } => "runtime_secret_crypto_error", } } pub fn runtime_error_context(error: &RuntimeError) -> Option { match error { RuntimeError::InvalidPreparedRequest { field, reason } => Some(json!({ "field": field, "reason": reason, })), RuntimeError::InvalidStreamingPayload { field, reason } => Some(json!({ "field": field, "reason": reason, })), RuntimeError::InvalidAuthSecretValue { secret_id, reason } => Some(json!({ "secret_id": secret_id, "reason": reason, })), RuntimeError::SecretCrypto { operation, details } => Some(json!({ "operation": operation, "details": details, })), RuntimeError::MissingAuthProfile { auth_profile_id } => Some(json!({ "auth_profile_id": auth_profile_id, })), RuntimeError::MissingSecret { secret_id } => Some(json!({ "secret_id": secret_id, })), RuntimeError::MissingSecretVersion { secret_id, version } => Some(json!({ "secret_id": secret_id, "version": version, })), RuntimeError::MissingStreamingConfig { operation_id } => Some(json!({ "operation_id": operation_id, })), RuntimeError::UnsupportedExecutionMode { operation_id, mode } => Some(json!({ "operation_id": operation_id, "mode": mode, })), RuntimeError::UnsupportedProtocol { protocol } => Some(json!({ "protocol": protocol, })), _ => None, } } #[cfg(test)] mod tests { use serde_json::json; use super::{runtime_error_context, runtime_test_failure}; use crank_runtime::RuntimeError; #[test] fn runtime_test_failure_includes_structured_context() { let payload = runtime_test_failure(&RuntimeError::InvalidPreparedRequest { field: "request.headers".to_owned(), reason: "must be an object".to_owned(), }); assert_eq!(payload["code"], "runtime_request_error"); assert_eq!( payload["context"], json!({ "field": "request.headers", "reason": "must be an object" }) ); } #[test] fn runtime_error_context_includes_secret_crypto_operation() { let context = runtime_error_context(&RuntimeError::SecretCrypto { operation: "decode secret envelope", details: "bad base64".to_owned(), }) .unwrap(); assert_eq!( context, json!({ "operation": "decode secret envelope", "details": "bad base64" }) ); } }