use alkcall::core::auth::Identity;
use alkcall::protocol::wire::CallError;
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Json, Response};
use serde_json::Value;
const PROTOCOL_CODE_NOT_FOUND: &str = "NOT_FOUND";
const PROTOCOL_CODE_FORBIDDEN: &str = "FORBIDDEN";
const PROTOCOL_CODE_INVALID_INPUT: &str = "INVALID_INPUT";
const PROTOCOL_CODE_INVALID_OPERATION_TYPE: &str = "INVALID_OPERATION_TYPE";
const PROTOCOL_CODE_TIMEOUT: &str = "TIMEOUT";
const PROTOCOL_CODE_ALREADY_EXISTS: &str = "ALREADY_EXISTS";
const PROTOCOL_CODE_CONNECTION_CLOSED: &str = "CONNECTION_CLOSED";
const PROTOCOL_CODE_INTERNAL: &str = "INTERNAL";
const HTTP_PREFIX: &str = "HTTP_";
const STATUS_NOT_FOUND: u16 = 404;
const STATUS_UNAUTHORIZED: u16 = 401;
const STATUS_FORBIDDEN: u16 = 403;
const STATUS_UNPROCESSABLE: u16 = 422;
const STATUS_TIMEOUT: u16 = 504;
const STATUS_CONFLICT: u16 = 409;
const STATUS_SERVICE_UNAVAILABLE: u16 = 503;
const STATUS_INTERNAL: u16 = 500;
const RETRY_AFTER_STATUSES: &[u16] = &[429, 503];
pub fn call_error_to_http_status(error: &CallError) -> u16 {
call_error_to_http_status_with_identity(error, None)
}
pub fn call_error_to_http_status_with_identity(
error: &CallError,
identity: Option<&Identity>,
) -> u16 {
match error.code.as_str() {
PROTOCOL_CODE_NOT_FOUND => STATUS_NOT_FOUND,
PROTOCOL_CODE_FORBIDDEN => {
if identity.is_some() {
STATUS_FORBIDDEN
} else {
STATUS_UNAUTHORIZED
}
}
PROTOCOL_CODE_INVALID_INPUT => STATUS_UNPROCESSABLE,
PROTOCOL_CODE_INVALID_OPERATION_TYPE => {
if identity.is_some() {
STATUS_UNPROCESSABLE
} else {
STATUS_UNAUTHORIZED
}
}
PROTOCOL_CODE_TIMEOUT => STATUS_TIMEOUT,
PROTOCOL_CODE_ALREADY_EXISTS => STATUS_CONFLICT,
PROTOCOL_CODE_CONNECTION_CLOSED => STATUS_SERVICE_UNAVAILABLE,
PROTOCOL_CODE_INTERNAL => STATUS_INTERNAL,
code if code.starts_with(HTTP_PREFIX) => code[HTTP_PREFIX.len()..]
.parse::<u16>()
.unwrap_or(STATUS_INTERNAL),
_ => STATUS_INTERNAL,
}
}
pub fn call_error_to_http_response(error: &CallError) -> Response {
call_error_to_http_response_with_identity(error, None)
}
pub fn call_error_to_http_response_with_identity(
error: &CallError,
identity: Option<&Identity>,
) -> Response {
let status_code = call_error_to_http_status_with_identity(error, identity);
let status = status_code_from_u16(status_code);
let body = serde_json::to_value(error).unwrap_or(Value::Null);
let retry_after = retry_after_value(error, status_code);
if let Some(retry_after) = retry_after {
let header_value =
HeaderValue::from_str(&retry_after).unwrap_or_else(|_| HeaderValue::from_static("0"));
(status, [(header::RETRY_AFTER, header_value)], Json(body)).into_response()
} else {
(status, Json(body)).into_response()
}
}
fn status_code_from_u16(code: u16) -> StatusCode {
StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
}
fn retry_after_value(error: &CallError, status_code: u16) -> Option<String> {
if !error.retryable || !RETRY_AFTER_STATUSES.contains(&status_code) {
return None;
}
error
.details
.as_ref()
.and_then(|details| details.get("retry_after"))
.and_then(Value::as_str)
.map(|s| s.to_string())
.or_else(|| {
error
.details
.as_ref()
.and_then(|details| details.get("retry_after"))
.and_then(Value::as_u64)
.map(|n| n.to_string())
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn identity() -> Identity {
Identity {
id: "caller".to_string(),
scopes: vec!["read".to_string()],
resources: HashMap::new(),
}
}
#[test]
fn not_found_maps_to_404() {
let error = CallError::not_found("fs/missing");
assert_eq!(call_error_to_http_status(&error), 404);
}
#[test]
fn invalid_input_maps_to_422() {
let error = CallError::invalid_input("bad input");
assert_eq!(call_error_to_http_status(&error), 422);
}
#[test]
fn invalid_operation_type_with_identity_maps_to_422() {
let error = CallError::invalid_operation_type("not a Pub op");
let id = identity();
assert_eq!(
call_error_to_http_status_with_identity(&error, Some(&id)),
422
);
}
#[test]
fn invalid_operation_type_without_identity_maps_to_401() {
let error = CallError::invalid_operation_type("not a Pub op");
assert_eq!(call_error_to_http_status_with_identity(&error, None), 401);
}
#[test]
fn timeout_maps_to_504() {
let error = CallError::timeout("timed out");
assert_eq!(call_error_to_http_status(&error), 504);
}
#[test]
fn already_exists_maps_to_409() {
let error = CallError::already_exists("name taken");
assert_eq!(call_error_to_http_status(&error), 409);
}
#[test]
fn connection_closed_maps_to_503() {
let error = CallError::connection_closed("undelivered");
assert_eq!(call_error_to_http_status(&error), 503);
}
#[test]
fn connection_closed_carries_retry_after_when_details_present() {
let error = CallError::connection_closed("undelivered")
.with_details(serde_json::json!({ "retry_after": "5" }));
let resp = call_error_to_http_response(&error);
assert_eq!(resp.status(), 503);
let retry_after = resp
.headers()
.get(header::RETRY_AFTER)
.map(|v| v.to_str().unwrap().to_string());
assert_eq!(retry_after.as_deref(), Some("5"));
}
#[test]
fn internal_maps_to_500() {
let error = CallError::internal("boom");
assert_eq!(call_error_to_http_status(&error), 500);
}
#[test]
fn forbidden_with_none_identity_maps_to_401() {
let error = CallError::forbidden("auth required");
assert_eq!(call_error_to_http_status_with_identity(&error, None), 401);
}
#[test]
fn forbidden_with_some_identity_maps_to_403() {
let error = CallError::forbidden("insufficient scopes");
let id = identity();
assert_eq!(
call_error_to_http_status_with_identity(&error, Some(&id)),
403
);
}
#[test]
fn http_prefixed_code_maps_to_declared_status() {
let error = CallError::new("HTTP_404", "not found", false);
assert_eq!(call_error_to_http_status(&error), 404);
}
#[test]
fn http_prefixed_code_with_unparseable_status_maps_to_500() {
let error = CallError::new("HTTP_", "malformed", false);
assert_eq!(call_error_to_http_status(&error), 500);
}
#[test]
fn unknown_domain_code_maps_to_500() {
let error = CallError::new("DOMAIN_SPECIFIC", "domain error", false);
assert_eq!(call_error_to_http_status(&error), 500);
}
#[test]
fn retryable_503_with_retry_after_details_sets_header() {
let error = CallError::new("HTTP_503", "overloaded", true)
.with_details(serde_json::json!({ "retry_after": "30" }));
let resp = call_error_to_http_response(&error);
assert_eq!(resp.status(), 503);
let retry_after = resp
.headers()
.get(header::RETRY_AFTER)
.map(|v| v.to_str().unwrap().to_string());
assert_eq!(retry_after.as_deref(), Some("30"));
}
#[test]
fn retry_after_present_on_identity_aware_path_for_429() {
let error = CallError::new("HTTP_429", "rate limited", true)
.with_details(serde_json::json!({ "retry_after": 12 }));
let resp = call_error_to_http_response_with_identity(&error, None);
assert_eq!(resp.status(), 429);
let retry_after = resp
.headers()
.get(header::RETRY_AFTER)
.map(|v| v.to_str().unwrap().to_string());
assert_eq!(retry_after.as_deref(), Some("12"));
}
#[test]
fn identity_aware_response_keeps_401_vs_403_split() {
let error = CallError::forbidden("insufficient scopes");
assert_eq!(
call_error_to_http_response_with_identity(&error, None).status(),
401
);
let id = identity();
assert_eq!(
call_error_to_http_response_with_identity(&error, Some(&id)).status(),
403
);
}
#[test]
fn non_retryable_503_does_not_set_retry_after() {
let error = CallError::new("HTTP_503", "overloaded", false)
.with_details(serde_json::json!({ "retry_after": "30" }));
let resp = call_error_to_http_response(&error);
assert_eq!(resp.status(), 503);
assert!(resp.headers().get(header::RETRY_AFTER).is_none());
}
#[test]
fn error_body_serializes_the_call_error() {
let error = CallError::not_found("fs/missing");
let resp = call_error_to_http_response(&error);
let bytes = futures::executor::block_on(axum::body::to_bytes(resp.into_body(), usize::MAX))
.unwrap();
let body: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body["code"], "NOT_FOUND");
assert_eq!(body["message"], "operation not found: fs/missing");
}
}