807 lines
30 KiB
Rust
807 lines
30 KiB
Rust
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::dto::OpenApiUploadLocale;
|
|
use crate::storage::StorageError;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum ApiError {
|
|
#[error("{message}")]
|
|
Unauthorized {
|
|
message: String,
|
|
context: Option<Value>,
|
|
},
|
|
#[error("{message}")]
|
|
Forbidden {
|
|
message: String,
|
|
context: Option<Value>,
|
|
},
|
|
#[error("{message}")]
|
|
Validation {
|
|
message: String,
|
|
context: Option<Value>,
|
|
},
|
|
#[error("{message}")]
|
|
Unprocessable {
|
|
message: String,
|
|
context: Option<Value>,
|
|
},
|
|
#[error("{message}")]
|
|
NotFound {
|
|
message: String,
|
|
context: Option<Value>,
|
|
},
|
|
#[error("{message}")]
|
|
Conflict {
|
|
message: String,
|
|
context: Option<Value>,
|
|
},
|
|
#[error("{message}")]
|
|
PayloadTooLarge {
|
|
message: String,
|
|
context: Option<Value>,
|
|
},
|
|
#[error("{message}")]
|
|
PreconditionRequired {
|
|
message: String,
|
|
context: Option<Value>,
|
|
},
|
|
#[error("{message}")]
|
|
RateLimited {
|
|
message: String,
|
|
context: Option<Value>,
|
|
},
|
|
#[error("{message}")]
|
|
Internal {
|
|
message: String,
|
|
context: Option<Value>,
|
|
},
|
|
}
|
|
|
|
impl ApiError {
|
|
pub fn unauthorized(message: impl Into<String>) -> Self {
|
|
Self::Unauthorized {
|
|
message: message.into(),
|
|
context: None,
|
|
}
|
|
}
|
|
|
|
pub fn forbidden(message: impl Into<String>) -> Self {
|
|
Self::Forbidden {
|
|
message: message.into(),
|
|
context: None,
|
|
}
|
|
}
|
|
|
|
pub fn validation(message: impl Into<String>) -> Self {
|
|
Self::Validation {
|
|
message: message.into(),
|
|
context: None,
|
|
}
|
|
}
|
|
|
|
pub fn internal(_message: impl Into<String>) -> Self {
|
|
Self::Internal {
|
|
message: "internal server error".to_owned(),
|
|
context: None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn openapi_upload(locale: OpenApiUploadLocale, code: &'static str) -> Self {
|
|
let russian = locale == OpenApiUploadLocale::Ru;
|
|
let message = match (russian, code) {
|
|
(_, "file_too_large") => {
|
|
if russian {
|
|
"файл OpenAPI превышает лимит 256 КиБ"
|
|
} else {
|
|
"OpenAPI file exceeds the 256 KiB limit"
|
|
}
|
|
}
|
|
(_, "empty_file") => {
|
|
if russian {
|
|
"файл OpenAPI не должен быть пустым"
|
|
} else {
|
|
"OpenAPI file must not be empty"
|
|
}
|
|
}
|
|
(_, "invalid_utf8") => {
|
|
if russian {
|
|
"файл OpenAPI должен быть в UTF-8"
|
|
} else {
|
|
"OpenAPI file must be UTF-8"
|
|
}
|
|
}
|
|
(_, "invalid_media_type") => {
|
|
if russian {
|
|
"тип файла OpenAPI не поддерживается"
|
|
} else {
|
|
"OpenAPI file type is not supported"
|
|
}
|
|
}
|
|
(_, "invalid_document") => {
|
|
if russian {
|
|
"некорректный или неподдерживаемый документ OpenAPI"
|
|
} else {
|
|
"OpenAPI document is invalid or unsupported"
|
|
}
|
|
}
|
|
(_, "no_methods") => {
|
|
if russian {
|
|
"документ OpenAPI не содержит поддерживаемых методов"
|
|
} else {
|
|
"OpenAPI document contains no supported methods"
|
|
}
|
|
}
|
|
(_, "source_integrity") => {
|
|
if russian {
|
|
"проверка целостности источника OpenAPI не пройдена"
|
|
} else {
|
|
"OpenAPI source integrity verification failed"
|
|
}
|
|
}
|
|
(_, "source_unavailable") => {
|
|
if russian {
|
|
"источник OpenAPI недоступен"
|
|
} else {
|
|
"OpenAPI source is unavailable"
|
|
}
|
|
}
|
|
(_, "parser_unavailable") | (_, "storage_unavailable") => {
|
|
if russian {
|
|
"обработка OpenAPI временно недоступна"
|
|
} else {
|
|
"OpenAPI processing is temporarily unavailable"
|
|
}
|
|
}
|
|
_ => {
|
|
if russian {
|
|
"некорректная multipart-загрузка OpenAPI"
|
|
} else {
|
|
"invalid OpenAPI multipart upload"
|
|
}
|
|
}
|
|
};
|
|
let context = json!({ "error_code": format!("openapi_upload.{code}") });
|
|
if code == "file_too_large" {
|
|
Self::PayloadTooLarge {
|
|
message: message.to_owned(),
|
|
context: Some(context),
|
|
}
|
|
} else if matches!(code, "storage_unavailable" | "parser_unavailable") {
|
|
Self::Internal {
|
|
message: message.to_owned(),
|
|
context: Some(context),
|
|
}
|
|
} else if matches!(code, "source_integrity" | "source_unavailable") {
|
|
Self::Unprocessable {
|
|
message: message.to_owned(),
|
|
context: Some(context),
|
|
}
|
|
} else {
|
|
Self::Validation {
|
|
message: message.to_owned(),
|
|
context: Some(context),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn source_unavailable() -> Self {
|
|
Self::openapi_upload(OpenApiUploadLocale::En, "source_unavailable")
|
|
}
|
|
|
|
pub(crate) fn source_integrity() -> Self {
|
|
Self::openapi_upload(OpenApiUploadLocale::En, "source_integrity")
|
|
}
|
|
|
|
pub(crate) fn rate_limited_with_context(message: impl Into<String>, context: Value) -> Self {
|
|
Self::RateLimited {
|
|
message: message.into(),
|
|
context: Some(context),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn validation_with_context(message: impl Into<String>, context: Value) -> Self {
|
|
Self::Validation {
|
|
message: message.into(),
|
|
context: Some(context),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn unprocessable_with_context(message: impl Into<String>, context: Value) -> Self {
|
|
Self::Unprocessable {
|
|
message: message.into(),
|
|
context: Some(context),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn not_found_with_context(message: impl Into<String>, context: Value) -> Self {
|
|
Self::NotFound {
|
|
message: message.into(),
|
|
context: Some(context),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn conflict_with_context(message: impl Into<String>, context: Value) -> Self {
|
|
Self::Conflict {
|
|
message: message.into(),
|
|
context: Some(context),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn payload_too_large_with_context(
|
|
message: impl Into<String>,
|
|
context: Value,
|
|
) -> Self {
|
|
Self::PayloadTooLarge {
|
|
message: message.into(),
|
|
context: Some(context),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn precondition_required_with_context(
|
|
message: impl Into<String>,
|
|
context: Value,
|
|
) -> Self {
|
|
Self::PreconditionRequired {
|
|
message: message.into(),
|
|
context: Some(context),
|
|
}
|
|
}
|
|
|
|
fn status_code(&self) -> StatusCode {
|
|
match self {
|
|
Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
|
|
Self::Forbidden { .. } => StatusCode::FORBIDDEN,
|
|
Self::Validation { .. } => StatusCode::BAD_REQUEST,
|
|
Self::Unprocessable { .. } => StatusCode::UNPROCESSABLE_ENTITY,
|
|
Self::NotFound { .. } => StatusCode::NOT_FOUND,
|
|
Self::Conflict { .. } => StatusCode::CONFLICT,
|
|
Self::PayloadTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
|
|
Self::PreconditionRequired { .. } => StatusCode::PRECONDITION_REQUIRED,
|
|
Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
|
|
Self::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn code(&self) -> &str {
|
|
if let Some(code) = self
|
|
.context_ref()
|
|
.and_then(|context| context.get("error_code"))
|
|
.and_then(Value::as_str)
|
|
{
|
|
return code;
|
|
}
|
|
match self {
|
|
Self::Unauthorized { .. } => "unauthorized",
|
|
Self::Forbidden { .. } => "forbidden",
|
|
Self::Validation { .. } => "validation_error",
|
|
Self::Unprocessable { .. } => "unprocessable_entity",
|
|
Self::NotFound { .. } => "not_found",
|
|
Self::Conflict { .. } => "conflict",
|
|
Self::PayloadTooLarge { .. } => "payload_too_large",
|
|
Self::PreconditionRequired { .. } => "precondition_required",
|
|
Self::RateLimited { .. } => "rate_limited",
|
|
Self::Internal { .. } => "internal_error",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for ApiError {
|
|
fn into_response(self) -> Response {
|
|
match &self {
|
|
Self::Internal { .. } => {
|
|
error!(
|
|
name: "admin.response.internal_error",
|
|
error_code = self.code(),
|
|
"internal API error response"
|
|
)
|
|
}
|
|
Self::Unauthorized { .. }
|
|
| Self::Forbidden { .. }
|
|
| Self::Validation { .. }
|
|
| Self::Unprocessable { .. }
|
|
| Self::NotFound { .. }
|
|
| Self::Conflict { .. }
|
|
| Self::PayloadTooLarge { .. }
|
|
| Self::PreconditionRequired { .. }
|
|
| Self::RateLimited { .. } => {
|
|
warn!(
|
|
name: "admin.response.rejected",
|
|
error_code = self.code(),
|
|
"API request rejected"
|
|
)
|
|
}
|
|
}
|
|
|
|
let mut error = json!({
|
|
"code": self.code(),
|
|
"message": self.to_string(),
|
|
});
|
|
if let Some(context) = self.context() {
|
|
error["context"] = context;
|
|
}
|
|
let (request_id, trace_id) = crank_observability::current_request_correlation();
|
|
if let Some(request_id) = request_id {
|
|
error["request_id"] = Value::String(request_id);
|
|
}
|
|
if let Some(trace_id) = trace_id {
|
|
error["trace_id"] = Value::String(trace_id);
|
|
}
|
|
|
|
let body = Json(json!({
|
|
"error": error
|
|
}));
|
|
|
|
(self.status_code(), body).into_response()
|
|
}
|
|
}
|
|
|
|
impl ApiError {
|
|
fn context_ref(&self) -> Option<&Value> {
|
|
match self {
|
|
Self::Unauthorized { context, .. }
|
|
| Self::Forbidden { context, .. }
|
|
| Self::Validation { context, .. }
|
|
| Self::Unprocessable { context, .. }
|
|
| Self::NotFound { context, .. }
|
|
| Self::Conflict { context, .. }
|
|
| Self::PayloadTooLarge { context, .. }
|
|
| Self::PreconditionRequired { context, .. }
|
|
| Self::RateLimited { context, .. }
|
|
| Self::Internal { context, .. } => context.as_ref(),
|
|
}
|
|
}
|
|
|
|
fn context(&self) -> Option<Value> {
|
|
self.context_ref().cloned()
|
|
}
|
|
}
|
|
|
|
impl From<RegistryError> for ApiError {
|
|
fn from(value: RegistryError) -> Self {
|
|
match value {
|
|
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_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::PlatformApiKeyInactive { key_id } => Self::conflict_with_context(
|
|
"platform api key is not active",
|
|
json!({
|
|
"key_id": key_id,
|
|
"error_code": "platform_api_key_not_active",
|
|
"recovery": "create_replacement_key"
|
|
}),
|
|
),
|
|
RegistryError::SecretNotFound { secret_id } => Self::not_found_with_context(
|
|
format!("secret {secret_id} was not found"),
|
|
json!({ "secret_id": secret_id }),
|
|
),
|
|
RegistryError::SecretInactive { secret_id } => Self::conflict_with_context(
|
|
"secret is not active",
|
|
json!({
|
|
"secret_id": secret_id,
|
|
"error_code": "secret_not_active",
|
|
"recovery": "rotate_or_replace_secret"
|
|
}),
|
|
),
|
|
RegistryError::SecretConcurrentUpdate { secret_id } => Self::conflict_with_context(
|
|
"secret was updated concurrently",
|
|
json!({
|
|
"secret_id": secret_id,
|
|
"error_code": "secret_concurrent_update",
|
|
"recovery": "reload"
|
|
}),
|
|
),
|
|
RegistryError::MasterKeyRotationInProgress => Self::conflict_with_context(
|
|
"master key rotation is in progress",
|
|
json!({
|
|
"error_code": "master_key_rotation_in_progress",
|
|
"recovery": "retry_after_rotation"
|
|
}),
|
|
),
|
|
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_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_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::OperationArchived { operation_id } => Self::conflict_with_context(
|
|
format!("operation {operation_id} is archived"),
|
|
json!({ "operation_id": operation_id, "error_code": "operation_archived" }),
|
|
),
|
|
RegistryError::OperationStaleVersion {
|
|
operation_id,
|
|
expected,
|
|
actual,
|
|
} => Self::conflict_with_context(
|
|
format!("operation {operation_id} has a stale base version"),
|
|
json!({
|
|
"operation_id": operation_id,
|
|
"current_version": expected,
|
|
"provided_version": actual,
|
|
"error_code": "operation_stale_version",
|
|
"recovery": "reload"
|
|
}),
|
|
),
|
|
RegistryError::InvalidOperationTransition {
|
|
operation_id,
|
|
from,
|
|
action,
|
|
} => Self::conflict_with_context(
|
|
format!("operation {operation_id} cannot perform {action} from {from}"),
|
|
json!({
|
|
"operation_id": operation_id,
|
|
"state": from,
|
|
"action": action,
|
|
"error_code": "operation_invalid_transition"
|
|
}),
|
|
),
|
|
RegistryError::OperationDeleteForbidden { operation_id } => {
|
|
Self::conflict_with_context(
|
|
format!(
|
|
"operation {operation_id} cannot be deleted because durable history exists"
|
|
),
|
|
json!({
|
|
"operation_id": operation_id,
|
|
"error_code": "operation_delete_forbidden"
|
|
}),
|
|
)
|
|
}
|
|
RegistryError::OperationAuthProfileUnavailable { operation_id } => {
|
|
Self::unprocessable_with_context(
|
|
"operation auth profile reference is unavailable",
|
|
json!({
|
|
"operation_id": operation_id,
|
|
"error_code": "operation_auth_profile_invalid"
|
|
}),
|
|
)
|
|
}
|
|
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::PlatformApiKeyNameAlreadyExists { workspace_id, name } => {
|
|
Self::conflict_with_context(
|
|
"platform api key name already exists",
|
|
json!({
|
|
"workspace_id": workspace_id,
|
|
"name": name,
|
|
"error_code": "platform_api_key_name_conflict",
|
|
"recovery": "choose_different_name"
|
|
}),
|
|
)
|
|
}
|
|
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,
|
|
"error_code": "secret_name_conflict",
|
|
"recovery": "choose_different_name"
|
|
}),
|
|
)
|
|
}
|
|
RegistryError::SecretReferencedByAuthProfile {
|
|
secret_id,
|
|
auth_profile_id,
|
|
} => 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,
|
|
"error_code": "secret_referenced_by_auth_profile",
|
|
"recovery": "remove_or_update_auth_profile_reference"
|
|
}),
|
|
),
|
|
RegistryError::UserEmailAlreadyExists { email } => Self::conflict_with_context(
|
|
format!("user with email {email} already exists"),
|
|
json!({ "email": email }),
|
|
),
|
|
RegistryError::AdminBootstrapUnavailable | RegistryError::AdminBootstrapRejected => {
|
|
Self::unauthorized("bootstrap request is invalid or expired")
|
|
}
|
|
RegistryError::AdminRecoveryRejected => Self::unauthorized("recovery request rejected"),
|
|
RegistryError::AdminLoginRateLimited { retry_after_ms } => {
|
|
Self::rate_limited_with_context(
|
|
"login temporarily unavailable",
|
|
json!({
|
|
"retry_after_ms": retry_after_ms.clamp(1, 300_000),
|
|
"error_code": "login_throttled",
|
|
"recovery": "retry_after_delay"
|
|
}),
|
|
)
|
|
}
|
|
RegistryError::AdminCsrfRejected => Self::forbidden("csrf validation failed"),
|
|
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::InvalidAgentVersionSequence {
|
|
agent_id,
|
|
expected,
|
|
actual,
|
|
} => Self::validation_with_context(
|
|
format!("agent {agent_id} expected next version {expected}, got {actual}"),
|
|
json!({
|
|
"agent_id": agent_id,
|
|
"expected": expected,
|
|
"actual": actual,
|
|
}),
|
|
),
|
|
RegistryError::ImmutableAgentVersion { agent_id, version } => {
|
|
Self::conflict_with_context(
|
|
"Agent Version is immutable; reload before editing",
|
|
json!({
|
|
"agent_id": agent_id,
|
|
"version": version,
|
|
"error_code": "agent_stale_revision",
|
|
"recovery": "reload"
|
|
}),
|
|
)
|
|
}
|
|
RegistryError::AgentStaleRevision { agent_id } => Self::conflict_with_context(
|
|
"Agent state changed; reload before retrying",
|
|
json!({
|
|
"agent_id": agent_id,
|
|
"error_code": "agent_stale_revision",
|
|
"recovery": "reload"
|
|
}),
|
|
),
|
|
RegistryError::InvalidAgentTransition {
|
|
agent_id,
|
|
from,
|
|
action,
|
|
} => Self::conflict_with_context(
|
|
format!("agent {agent_id} cannot transition from {from} using {action}"),
|
|
json!({
|
|
"agent_id": agent_id,
|
|
"from": from,
|
|
"action": action,
|
|
"error_code": "agent_invalid_transition",
|
|
"recovery": "reload"
|
|
}),
|
|
),
|
|
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::OnboardingStaleRevision => Self::conflict_with_context(
|
|
"onboarding state changed; reload before retrying",
|
|
json!({"error_code":"onboarding_stale_revision","recovery":"reload"}),
|
|
),
|
|
RegistryError::OnboardingIncomplete => Self::unprocessable_with_context(
|
|
"onboarding domain steps are not complete",
|
|
json!({"error_code":"onboarding_incomplete"}),
|
|
),
|
|
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::ImportJobNotFound { job_id } => Self::not_found_with_context(
|
|
format!("import job {job_id} was not found"),
|
|
json!({ "job_id": job_id }),
|
|
),
|
|
RegistryError::ImportJobAlreadyApplied { job_id } => Self::conflict_with_context(
|
|
format!("import job {job_id} was already applied with different parameters"),
|
|
json!({ "job_id": job_id }),
|
|
),
|
|
RegistryError::SourceNotFound { .. } => Self::not_found_with_context(
|
|
"artifact source was not found",
|
|
json!({
|
|
"error_code": "artifact_source_not_found"
|
|
}),
|
|
),
|
|
RegistryError::SourceConflict { .. } => Self::conflict_with_context(
|
|
"artifact source metadata or lifecycle conflicts with the request",
|
|
json!({
|
|
"error_code": "artifact_source_conflict",
|
|
"recovery": "reload"
|
|
}),
|
|
),
|
|
RegistryError::SourceUnavailable => Self::unprocessable_with_context(
|
|
"artifact source is unavailable",
|
|
json!({ "error_code": "artifact_source_unavailable" }),
|
|
),
|
|
RegistryError::SourceIntegrity => Self::unprocessable_with_context(
|
|
"artifact source failed integrity verification",
|
|
json!({ "error_code": "artifact_source_integrity" }),
|
|
),
|
|
RegistryError::ArtifactClaimInProgress => Self::conflict_with_context(
|
|
"artifact reconciliation is in progress",
|
|
json!({
|
|
"error_code": "artifact_claim_in_progress",
|
|
"recovery": "retry"
|
|
}),
|
|
),
|
|
RegistryError::InvalidArtifactSource { field } => Self::validation_with_context(
|
|
"artifact source metadata is invalid",
|
|
json!({
|
|
"field": field,
|
|
"error_code": "artifact_source_invalid"
|
|
}),
|
|
),
|
|
RegistryError::Storage(_) => Self::internal("registry operation failed"),
|
|
RegistryError::Migration(_)
|
|
| RegistryError::Serialization(_)
|
|
| RegistryError::MasterKeyIdentityMismatch { .. }
|
|
| RegistryError::InvalidMasterKeyIdentity
|
|
| RegistryError::MasterKeyRotationNotFound { .. }
|
|
| RegistryError::MasterKeyRotationConflict
|
|
| RegistryError::MasterKeyRotationVerificationFailed
|
|
| RegistryError::InvalidCorrelationIdentity { .. }
|
|
| RegistryError::InvalidExecutionRecord { .. } => {
|
|
Self::internal("registry operation failed")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<MappingError> for ApiError {
|
|
fn from(value: MappingError) -> Self {
|
|
Self::validation(value.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<SchemaError> for ApiError {
|
|
fn from(value: SchemaError) -> Self {
|
|
Self::validation(value.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<StorageError> for ApiError {
|
|
fn from(value: StorageError) -> Self {
|
|
match value {
|
|
StorageError::InvalidStorageRef { details } => Self::validation_with_context(
|
|
format!("invalid storage reference: {details}"),
|
|
json!({ "details": details }),
|
|
),
|
|
StorageError::Io(_) | StorageError::Serialization(_) => {
|
|
Self::internal(value.to_string())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn runtime_test_failure(error: &RuntimeError) -> Value {
|
|
let failure =
|
|
crank_runtime::normalize_runtime_error(error, &crank_core::CorrelationContext::generate());
|
|
execution_test_failure(&failure)
|
|
}
|
|
|
|
pub fn execution_test_failure(failure: &crank_core::ExecutionFailure) -> Value {
|
|
execution_test_failure_localized(failure, crank_core::ExecutionLocale::En)
|
|
}
|
|
|
|
pub fn execution_test_failure_localized(
|
|
failure: &crank_core::ExecutionFailure,
|
|
locale: crank_core::ExecutionLocale,
|
|
) -> Value {
|
|
let mut payload = json!({
|
|
"code": failure.error_code().as_str(),
|
|
"message": failure.error_code().message(locale),
|
|
"stage": failure.stage().as_str(),
|
|
"retryability": failure.retryability().as_str(),
|
|
"outcome_certainty": failure.outcome_certainty().as_str(),
|
|
});
|
|
if let Some(status) = failure.upstream_status() {
|
|
payload["context"] = json!({ "upstream_status": status });
|
|
}
|
|
if let Some(challenge) = failure.confirmation() {
|
|
payload["context"] = json!({
|
|
"confirmation_token": challenge.token(),
|
|
"expires_in_ms": challenge.expires_in_ms(),
|
|
});
|
|
}
|
|
payload
|
|
}
|