chore: publish clean community baseline
This commit is contained in:
@@ -0,0 +1,583 @@
|
||||
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,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
Forbidden {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
Validation {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
NotFound {
|
||||
message: String,
|
||||
context: Option<Value>,
|
||||
},
|
||||
#[error("{message}")]
|
||||
Conflict {
|
||||
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: message.into(),
|
||||
context: None,
|
||||
}
|
||||
}
|
||||
|
||||
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 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 forbidden_with_context(message: impl Into<String>, context: Value) -> Self {
|
||||
Self::Forbidden {
|
||||
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::NotFound { .. } => StatusCode::NOT_FOUND,
|
||||
Self::Conflict { .. } => StatusCode::CONFLICT,
|
||||
Self::RateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
|
||||
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::RateLimited { .. } => "rate_limited",
|
||||
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, .. }
|
||||
| Self::RateLimited { 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": error
|
||||
}));
|
||||
|
||||
(self.status_code(), body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
fn context(&self) -> Option<Value> {
|
||||
match self {
|
||||
Self::Unauthorized { context, .. }
|
||||
| Self::Forbidden { context, .. }
|
||||
| Self::Validation { context, .. }
|
||||
| Self::NotFound { context, .. }
|
||||
| Self::Conflict { context, .. }
|
||||
| Self::RateLimited { context, .. }
|
||||
| Self::Internal { context, .. } => context.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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::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_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::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_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::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::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::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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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::ProtocolAdapter(_) => "runtime_adapter_error",
|
||||
RuntimeError::SoapAdapter(_) => "runtime_soap_error",
|
||||
RuntimeError::WebsocketAdapter(_) => "runtime_websocket_error",
|
||||
RuntimeError::UnsupportedProtocol { .. } => "runtime_protocol_error",
|
||||
RuntimeError::ConcurrencyLimitExceeded { .. } => "runtime_overloaded",
|
||||
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<Value> {
|
||||
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,
|
||||
})),
|
||||
RuntimeError::ConcurrencyLimitExceeded { kind, limit } => Some(json!({
|
||||
"kind": kind,
|
||||
"limit": limit,
|
||||
})),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{ApiError, runtime_error_context, runtime_test_failure};
|
||||
use crank_registry::RegistryError;
|
||||
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"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_test_failure_includes_runtime_overload_context() {
|
||||
let payload = runtime_test_failure(&RuntimeError::ConcurrencyLimitExceeded {
|
||||
kind: "window",
|
||||
limit: 16,
|
||||
});
|
||||
|
||||
assert_eq!(payload["code"], "runtime_overloaded");
|
||||
assert_eq!(
|
||||
payload["context"],
|
||||
json!({
|
||||
"kind": "window",
|
||||
"limit": 16
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user