diff --git a/apps/admin-api/src/error.rs b/apps/admin-api/src/error.rs index 6cb622d..d99cbf8 100644 --- a/apps/admin-api/src/error.rs +++ b/apps/admin-api/src/error.rs @@ -16,53 +16,98 @@ use crate::storage::StorageError; #[derive(Debug, Error)] pub enum ApiError { #[error("{message}")] - Unauthorized { message: String }, + Unauthorized { + message: String, + context: Option, + }, #[error("{message}")] - Forbidden { message: String }, + Forbidden { + message: String, + context: Option, + }, #[error("{message}")] - Validation { message: String }, + Validation { + message: String, + context: Option, + }, #[error("{message}")] - NotFound { message: String }, + NotFound { + message: String, + context: Option, + }, #[error("{message}")] - Conflict { message: String }, + Conflict { + message: String, + context: Option, + }, #[error("{message}")] - Internal { message: String }, + Internal { + message: String, + context: Option, + }, } impl ApiError { pub fn unauthorized(message: impl Into) -> Self { Self::Unauthorized { message: message.into(), + context: None, } } pub fn forbidden(message: impl Into) -> Self { Self::Forbidden { message: message.into(), + context: None, } } pub fn validation(message: impl Into) -> Self { Self::Validation { message: message.into(), + context: None, } } pub fn not_found(message: impl Into) -> Self { Self::NotFound { message: message.into(), + context: None, } } pub fn conflict(message: impl Into) -> Self { Self::Conflict { message: message.into(), + context: None, } } pub fn internal(message: impl Into) -> Self { Self::Internal { message: message.into(), + context: None, + } + } + + fn validation_with_context(message: impl Into, context: Value) -> Self { + Self::Validation { + message: message.into(), + context: Some(context), + } + } + + fn not_found_with_context(message: impl Into, context: Value) -> Self { + Self::NotFound { + message: message.into(), + context: Some(context), + } + } + + fn conflict_with_context(message: impl Into, context: Value) -> Self { + Self::Conflict { + message: message.into(), + context: Some(context), } } @@ -92,114 +137,232 @@ impl ApiError { impl IntoResponse for ApiError { fn into_response(self) -> Response { match &self { - Self::Internal { message } => { + 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 } => { + Self::Unauthorized { message, .. } + | Self::Forbidden { message, .. } + | Self::Validation { message, .. } + | Self::NotFound { message, .. } + | Self::Conflict { message, .. } => { warn!(error_code = self.code(), error_message = %message) } } + let mut error = json!({ + "code": self.code(), + "message": self.to_string(), + }); + if let Some(context) = self.context() { + error["context"] = context; + } + let body = Json(json!({ - "error": { - "code": self.code(), - "message": self.to_string(), - } + "error": error })); (self.status_code(), body).into_response() } } +impl ApiError { + fn context(&self) -> Option { + match self { + Self::Unauthorized { context, .. } + | Self::Forbidden { context, .. } + | Self::Validation { context, .. } + | Self::NotFound { context, .. } + | Self::Conflict { context, .. } + | Self::Internal { context, .. } => context.clone(), + } + } +} + 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::WorkspaceNotFound { workspace_id } => Self::not_found_with_context( + format!("workspace {workspace_id} was not found"), + json!({ "workspace_id": workspace_id }), + ), + RegistryError::UserNotFound { user_id } => Self::not_found_with_context( + format!("user {user_id} was not found"), + json!({ "user_id": user_id }), + ), 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")) - } + } => Self::not_found_with_context( + format!("membership for user {user_id} in workspace {workspace_id} was not found"), + json!({ + "workspace_id": workspace_id, + "user_id": user_id, + }), + ), + RegistryError::AgentNotFound { agent_id } => Self::not_found_with_context( + format!("agent {agent_id} was not found"), + json!({ "agent_id": agent_id }), + ), + RegistryError::InvitationNotFound { invitation_id } => Self::not_found_with_context( + format!("invitation {invitation_id} was not found"), + json!({ "invitation_id": invitation_id }), + ), + RegistryError::PlatformApiKeyNotFound { key_id } => Self::not_found_with_context( + format!("platform api key {key_id} was not found"), + json!({ "key_id": key_id }), + ), + RegistryError::SecretNotFound { secret_id } => Self::not_found_with_context( + format!("secret {secret_id} was not found"), + json!({ "secret_id": secret_id }), + ), + RegistryError::StreamSessionNotFound { session_id } => Self::not_found_with_context( + format!("stream session {session_id} was not found"), + json!({ "session_id": session_id }), + ), + RegistryError::AsyncJobNotFound { job_id } => Self::not_found_with_context( + format!("async job {job_id} was not found"), + json!({ "job_id": job_id }), + ), + RegistryError::InvocationLogNotFound { log_id } => Self::not_found_with_context( + format!("invocation log {log_id} was not found"), + json!({ "log_id": log_id }), + ), 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")) - } + } => Self::not_found_with_context( + format!("published agent {workspace_slug}/{agent_slug} was not found"), + json!({ + "workspace_slug": workspace_slug, + "agent_slug": agent_slug, + }), + ), + RegistryError::OperationNotFound { operation_id } => Self::not_found_with_context( + format!("operation {operation_id} was not found"), + json!({ "operation_id": operation_id }), + ), 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}"), + } => Self::not_found_with_context( + format!("operation version {version} for {operation_id} was not found"), + json!({ + "operation_id": operation_id, + "version": version, + }), ), + RegistryError::OperationHasPublishedAgentBindings { operation_id } => { + Self::conflict_with_context( + format!( + "operation {operation_id} cannot be deleted while it is bound to a published agent" + ), + json!({ "operation_id": operation_id }), + ) + } + RegistryError::AuthProfileNotFound { auth_profile_id } => Self::not_found_with_context( + format!("auth profile {auth_profile_id} was not found"), + json!({ "auth_profile_id": auth_profile_id }), + ), + RegistryError::OperationAlreadyExists { operation_id } => Self::conflict_with_context( + format!("operation {operation_id} already exists"), + json!({ "operation_id": operation_id }), + ), + RegistryError::WorkspaceSlugAlreadyExists { slug } => Self::conflict_with_context( + format!("workspace with slug {slug} already exists"), + json!({ "slug": slug }), + ), + RegistryError::SecretNameAlreadyExists { workspace_id, name } => { + Self::conflict_with_context( + format!("secret with name {name} already exists in workspace {workspace_id}"), + json!({ + "workspace_id": workspace_id, + "name": name, + }), + ) + } 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")) + } => Self::conflict_with_context( + format!("secret {secret_id} is referenced by auth profile {auth_profile_id}"), + json!({ + "secret_id": secret_id, + "auth_profile_id": auth_profile_id, + }), + ), + RegistryError::InvalidStreamSessionTransition { + session_id, + from, + to, + } => Self::conflict_with_context( + format!("invalid stream session transition for {session_id}: {from} -> {to}"), + json!({ + "session_id": session_id, + "from": from, + "to": to, + }), + ), + RegistryError::InvalidAsyncJobTransition { job_id, from, to } => { + Self::conflict_with_context( + format!("invalid async job transition for {job_id}: {from} -> {to}"), + json!({ + "job_id": job_id, + "from": from, + "to": to, + }), + ) } - RegistryError::InvalidInitialVersion { .. } - | RegistryError::InvalidVersionSequence { .. } - | RegistryError::ImmutableOperationFieldChanged { .. } - | RegistryError::InvalidEnumRepresentation { .. } - | RegistryError::InvalidNumericValue { .. } - | RegistryError::YamlImportJobNotFound { .. } => Self::validation(value.to_string()), + RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context( + format!("user with email {email} already exists"), + json!({ "email": email }), + ), + RegistryError::InvalidInitialVersion { + operation_id, + version, + } => Self::validation_with_context( + format!("operation {operation_id} must start with version 1, got {version}"), + json!({ + "operation_id": operation_id, + "version": version, + }), + ), + RegistryError::InvalidVersionSequence { + operation_id, + expected, + actual, + } => Self::validation_with_context( + format!("operation {operation_id} expected next version {expected}, got {actual}"), + json!({ + "operation_id": operation_id, + "expected": expected, + "actual": actual, + }), + ), + RegistryError::ImmutableOperationFieldChanged { + operation_id, + field, + } => Self::validation_with_context( + format!("operation {operation_id} changed immutable field {field}"), + json!({ + "operation_id": operation_id, + "field": field, + }), + ), + RegistryError::InvalidEnumRepresentation { field } => Self::validation_with_context( + format!("unsupported enum representation for field {field}"), + json!({ "field": field }), + ), + RegistryError::InvalidNumericValue { field, value } => Self::validation_with_context( + format!("invalid numeric value for field {field}: {value}"), + json!({ + "field": field, + "value": value, + }), + ), + RegistryError::YamlImportJobNotFound { job_id } => Self::validation_with_context( + format!("yaml import job {job_id} was not found"), + json!({ "job_id": job_id }), + ), RegistryError::Storage(_) | RegistryError::Serialization(_) => { Self::internal(value.to_string()) } @@ -222,7 +385,10 @@ impl From for ApiError { impl From for ApiError { fn from(value: StorageError) -> Self { match value { - StorageError::InvalidStorageRef { details } => Self::validation(details), + StorageError::InvalidStorageRef { details } => Self::validation_with_context( + format!("invalid storage reference: {details}"), + json!({ "details": details }), + ), StorageError::Io(_) | StorageError::Serialization(_) => { Self::internal(value.to_string()) } @@ -310,7 +476,8 @@ pub fn runtime_error_context(error: &RuntimeError) -> Option { mod tests { use serde_json::json; - use super::{runtime_error_context, runtime_test_failure}; + use super::{ApiError, runtime_error_context, runtime_test_failure}; + use crank_registry::RegistryError; use crank_runtime::RuntimeError; #[test] @@ -346,4 +513,27 @@ mod tests { }) ); } + + #[test] + fn registry_errors_preserve_structured_context_in_api_error() { + let error = ApiError::from(RegistryError::InvalidAsyncJobTransition { + job_id: "job_123".to_owned(), + from: "running".to_owned(), + to: "completed".to_owned(), + }); + + match error { + ApiError::Conflict { context, .. } => { + assert_eq!( + context, + Some(json!({ + "job_id": "job_123", + "from": "running", + "to": "completed" + })) + ); + } + other => panic!("unexpected error variant: {other:?}"), + } + } }