maincopy-server 0.1.0

Self-hosted publishing server with exact previews and explicit release approval
Documentation
use axum::{
    Json,
    http::StatusCode,
    response::{IntoResponse as _, Response},
};
use serde::Serialize;
use utoipa::ToSchema;

use super::request_id::RequestId;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct AdminProblem {
    pub(crate) status: StatusCode,
    pub(crate) code: &'static str,
    pub(crate) message: &'static str,
}

impl AdminProblem {
    pub(crate) const fn new(status: StatusCode, code: &'static str, message: &'static str) -> Self {
        Self {
            status,
            code,
            message,
        }
    }

    pub(crate) const fn bad_request(code: &'static str, message: &'static str) -> Self {
        Self::new(StatusCode::BAD_REQUEST, code, message)
    }

    pub(crate) const fn forbidden(code: &'static str, message: &'static str) -> Self {
        Self::new(StatusCode::FORBIDDEN, code, message)
    }

    pub(crate) const fn conflict(code: &'static str, message: &'static str) -> Self {
        Self::new(StatusCode::CONFLICT, code, message)
    }

    pub(crate) const fn internal(code: &'static str, message: &'static str) -> Self {
        Self::new(StatusCode::INTERNAL_SERVER_ERROR, code, message)
    }

    pub(crate) const fn unavailable(code: &'static str, message: &'static str) -> Self {
        Self::new(StatusCode::SERVICE_UNAVAILABLE, code, message)
    }
}

pub(crate) fn problem_response(problem: AdminProblem, request_id: RequestId) -> Response {
    (
        problem.status,
        Json(AdminProblemEnvelope {
            error: AdminProblemBody {
                code: problem.code,
                message: problem.message,
                request_id: request_id.to_string().into_boxed_str(),
            },
        }),
    )
        .into_response()
}

#[derive(Serialize, ToSchema)]
pub(crate) struct AdminProblemEnvelope {
    error: AdminProblemBody,
}

#[derive(Serialize, ToSchema)]
struct AdminProblemBody {
    code: &'static str,
    message: &'static str,
    request_id: Box<str>,
}

#[cfg(test)]
mod tests {
    use axum::{body::to_bytes, http::StatusCode};
    use serde_json::Value;
    use uuid::Uuid;

    use super::*;

    #[tokio::test]
    async fn response_preserves_the_stable_problem_contract() {
        let request_id = RequestId(Uuid::new_v4());
        let response = problem_response(
            AdminProblem {
                status: StatusCode::CONFLICT,
                code: "resource_conflict",
                message: "the resource changed",
            },
            request_id,
        );

        assert_eq!(response.status(), StatusCode::CONFLICT);
        let body: Value = serde_json::from_slice(
            &to_bytes(response.into_body(), 1024)
                .await
                .expect("the problem body must be bounded"),
        )
        .expect("the problem body must be JSON");
        assert_eq!(body["error"]["code"], "resource_conflict");
        assert_eq!(body["error"]["message"], "the resource changed");
        assert_eq!(body["error"]["request_id"], request_id.to_string());
    }
}