alkhttp 0.4.1

HTTP interface for the alk stack: serves HTTP/1.1 + HTTP/2 on standard ALPNs (with WebSocket upgrade carrying the channels protocol) and hosts the HTTP-backed call-protocol adapters
Documentation
//! CallError → HTTP status/response mapping ([ADR-023]).
//!
//! Protocol-level vs operation-level code distinction: protocol codes
//! (`NOT_FOUND`, `FORBIDDEN`, `INVALID_INPUT`, `INVALID_OPERATION_TYPE`,
//! `TIMEOUT`, `ALREADY_EXISTS` — the seventh code, alkcall ADR-022's
//! `op/register` collision policy, mapped 409 Conflict, review-006
//! UP-01; `CONNECTION_CLOSED` — the eighth code, alkcall ADR-016's
//! retryable undelivered-call code, mapped 503 Service Unavailable,
//! alkcall 0.7.0's CF-007 amendment made the list formal) map to fixed
//! statuses; operation-level codes imported from external HTTP APIs are
//! prefixed `HTTP_<status>` and map to their declared status.
//!
//! The identity-aware variant maps the ambiguous protocol codes
//! (`FORBIDDEN`, `INVALID_OPERATION_TYPE`) to `401` when no token
//! resolved and `403`/`422` when one did — the HTTP-specific refinement
//! the neutral [`call_error_to_http_response`] cannot express (the
//! gateway's auth middleware has already run by the time it is invoked,
//! so live gateway paths always route through the identity-aware
//! variant).
//!
//! [ADR-023]: https://docs.rs/alkhttp (docs/architecture/decisions)

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];

/// Map a `CallError` to its HTTP status code (identity-blind variant:
/// ambiguous codes resolve as if no identity were present).
pub fn call_error_to_http_status(error: &CallError) -> u16 {
    call_error_to_http_status_with_identity(error, None)
}

/// Identity-aware status mapping: ambiguous protocol codes
/// (`FORBIDDEN`, `INVALID_OPERATION_TYPE`) map 401 without an identity
/// (you are not authenticated) and 403/422 with one (you are, but
/// lack the authority).
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,
    }
}

/// Map a `CallError` to a full HTTP response — status from
/// [`call_error_to_http_status`], the serialized `CallError` as the
/// JSON body.
pub fn call_error_to_http_response(error: &CallError) -> Response {
    call_error_to_http_response_with_identity(error, None)
}

/// Identity-aware variant of [`call_error_to_http_response`]: the
/// ambiguous protocol codes (`FORBIDDEN`, `INVALID_OPERATION_TYPE`) map
/// to `401` without a caller identity and `403`/`422` with one. Live
/// gateway error paths resolve the identity first, so they route
/// through this variant.
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");
    }
}