maincopy-server 0.1.0

Self-hosted publishing server with exact previews and explicit release approval
Documentation
use axum::{
    Json,
    extract::{
        DefaultBodyLimit, Path, Query, State,
        rejection::{JsonRejection, PathRejection, QueryRejection},
    },
    http::{HeaderMap, StatusCode},
    response::{IntoResponse as _, Response},
};
use maincopy_shared::publication::{
    ChangeReleaseRequest, ListReleasesResponse, ReleaseBlockReason, ReleaseOperationResource,
    ReleaseResource, ReleaseState,
};
use serde::Deserialize;
use time::OffsetDateTime;
use utoipa_axum::{
    router::{UtoipaMethodRouter, UtoipaMethodRouterExt as _},
    routes,
};
use uuid::Uuid;

use super::{
    ErrorSpec, MAX_PUBLICATION_REQUEST_BYTES, activation_error, idempotency_key, problem,
    wire_preview_digest,
};
use crate::admin::{AdminRuntimeState, problem::AdminProblemEnvelope, request_id::RequestId};
use crate::domain::publication::{
    ActivationBlockReason, CanonicalState,
    activation::{PublicationCoordinatorHandle, ReleaseTransitionError, RetryRelease},
    store::{
        ChangeRelease, ReleaseChange, ReleaseChangeReceipt, ReleaseCommandError, ReleaseLoadError,
        ReleaseMutationError, ReleaseView,
    },
};

pub(crate) fn list_routes() -> UtoipaMethodRouter<AdminRuntimeState> {
    routes!(list_releases)
}
pub(crate) fn item_routes() -> UtoipaMethodRouter<AdminRuntimeState> {
    routes!(get_release, change_release).layer(DefaultBodyLimit::max(MAX_PUBLICATION_REQUEST_BYTES))
}
pub(crate) fn operation_routes() -> UtoipaMethodRouter<AdminRuntimeState> {
    routes!(get_operation)
}

#[derive(Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReleaseQuery {
    cursor: Option<Uuid>,
}

#[utoipa::path(get, path = "/api/admin/v1/releases",
    params(("cursor" = Option<Uuid>, Query, description = "Publication UUID from next_cursor; pages contain at most 100 releases")),
    responses((status = OK, body = ListReleasesResponse), (status = BAD_REQUEST, body = AdminProblemEnvelope), (status = SERVICE_UNAVAILABLE, body = AdminProblemEnvelope)), tag = "Releases")]
async fn list_releases(
    request_id: RequestId,
    query: Result<Query<ReleaseQuery>, QueryRejection>,
    State(coordinator): State<PublicationCoordinatorHandle>,
) -> Response {
    let Ok(Query(query)) = query else {
        return invalid_input(request_id);
    };
    match coordinator.releases(query.cursor).await {
        Ok(mut releases) => {
            let next_cursor = if releases.len() > 100 {
                Some(releases[99].publication_id)
            } else {
                None
            };
            releases.truncate(100);
            Json(ListReleasesResponse {
                releases: releases.into_iter().map(release_resource).collect(),
                next_cursor,
            })
            .into_response()
        }
        Err(error) => load_problem(error, request_id),
    }
}

#[utoipa::path(get, path = "/api/admin/v1/releases/{publication_id}",
    params(("publication_id" = Uuid, Path)),
    responses((status = OK, body = ReleaseResource), (status = BAD_REQUEST, body = AdminProblemEnvelope), (status = NOT_FOUND, body = AdminProblemEnvelope), (status = SERVICE_UNAVAILABLE, body = AdminProblemEnvelope)), tag = "Releases")]
async fn get_release(
    request_id: RequestId,
    id: Result<Path<Uuid>, PathRejection>,
    State(coordinator): State<PublicationCoordinatorHandle>,
) -> Response {
    let Ok(Path(id)) = id else {
        return invalid_input(request_id);
    };
    match coordinator.release(id).await {
        Ok(Some(release)) => Json(release_resource(release)).into_response(),
        Ok(None) => problem(
            ErrorSpec::new(
                StatusCode::NOT_FOUND,
                "release_not_found",
                "the release does not exist",
            ),
            request_id,
        ),
        Err(error) => load_problem(error, request_id),
    }
}

#[utoipa::path(get, path = "/api/admin/v1/release-operations/{operation_id}",
    params(("operation_id" = Uuid, Path)),
    responses((status = OK, description = "Immutable accepted result; inspect the release for current state", body = ReleaseOperationResource), (status = BAD_REQUEST, body = AdminProblemEnvelope), (status = NOT_FOUND, body = AdminProblemEnvelope), (status = SERVICE_UNAVAILABLE, body = AdminProblemEnvelope)), tag = "Releases")]
async fn get_operation(
    request_id: RequestId,
    id: Result<Path<Uuid>, PathRejection>,
    State(coordinator): State<PublicationCoordinatorHandle>,
) -> Response {
    let Ok(Path(id)) = id else {
        return invalid_input(request_id);
    };
    match coordinator.release_operation(id).await {
        Ok(Some(receipt)) => Json(operation_resource(receipt)).into_response(),
        Ok(None) => problem(
            ErrorSpec::new(
                StatusCode::NOT_FOUND,
                "release_operation_not_found",
                "no accepted release operation exists for this identifier",
            ),
            request_id,
        ),
        Err(error) => load_problem(error, request_id),
    }
}

#[utoipa::path(post, path = "/api/admin/v1/releases/{publication_id}",
    params(("publication_id" = Uuid, Path), ("Idempotency-Key" = Uuid, Header, description = "Stable operation UUID; reuse only with the identical command")),
    request_body = ChangeReleaseRequest,
    responses(
        (status = OK, description = "Immutable accepted result; retry acceptance can precede final publication", body = ReleaseOperationResource),
        (status = BAD_REQUEST, body = AdminProblemEnvelope),
        (status = NOT_FOUND, body = AdminProblemEnvelope),
        (status = CONFLICT, body = AdminProblemEnvelope),
        (status = PRECONDITION_FAILED, body = AdminProblemEnvelope),
        (status = PAYLOAD_TOO_LARGE, body = AdminProblemEnvelope),
        (status = SERVICE_UNAVAILABLE, body = AdminProblemEnvelope),
        (status = INTERNAL_SERVER_ERROR, body = AdminProblemEnvelope)
    ), tag = "Releases")]
async fn change_release(
    request_id: RequestId,
    id: Result<Path<Uuid>, PathRejection>,
    headers: HeaderMap,
    State(coordinator): State<PublicationCoordinatorHandle>,
    body: Result<Json<ChangeReleaseRequest>, JsonRejection>,
) -> Response {
    let Ok(Path(publication_id)) = id else {
        return invalid_input(request_id);
    };
    let operation_id = match idempotency_key(&headers) {
        Ok(id) => id,
        Err(spec) => return problem(spec, request_id),
    };
    let request = match body {
        Ok(Json(request)) => request,
        Err(error) => return invalid_change_body(error, request_id),
    };
    match apply_control(&coordinator, publication_id, operation_id, request).await {
        Ok(receipt) => Json(operation_resource(receipt)).into_response(),
        Err(error) => {
            let spec = transition_error(&error);
            if spec.status.is_server_error() {
                tracing::error!(%request_id, %publication_id, %operation_id, %error, "admin release control failed");
            }
            problem(spec, request_id)
        }
    }
}

async fn apply_control(
    coordinator: &PublicationCoordinatorHandle,
    publication_id: Uuid,
    operation_id: Uuid,
    request: ChangeReleaseRequest,
) -> Result<ReleaseChangeReceipt, ReleaseTransitionError> {
    let now = OffsetDateTime::now_utc();
    let (expected_version, change) = match request {
        ChangeReleaseRequest::Retry { expected_version } => {
            return coordinator
                .retry_release(RetryRelease {
                    operation_id,
                    publication_id,
                    expected_version,
                    now,
                })
                .await;
        }
        ChangeReleaseRequest::Reschedule {
            expected_version,
            scheduled_for,
        } => (
            expected_version,
            ReleaseChange::Reschedule {
                scheduled_at: scheduled_for,
            },
        ),
        ChangeReleaseRequest::Cancel { expected_version } => {
            (expected_version, ReleaseChange::Cancel)
        }
    };
    coordinator
        .change_release(ChangeRelease {
            operation_id,
            publication_id,
            expected_version,
            change,
            now,
        })
        .await
}

fn invalid_change_body(error: JsonRejection, request_id: RequestId) -> Response {
    let spec = if error.status() == StatusCode::PAYLOAD_TOO_LARGE {
        ErrorSpec::new(
            StatusCode::PAYLOAD_TOO_LARGE,
            "request_body_too_large",
            "the request body must not exceed 4096 bytes",
        )
    } else {
        ErrorSpec::bad_request(
            "invalid_release_change",
            "provide a known action, an exact positive version, and a UTC time when rescheduling",
        )
    };
    problem(spec, request_id)
}

fn transition_error(error: &ReleaseTransitionError) -> ErrorSpec {
    match error {
        ReleaseTransitionError::Activation(error) => activation_error(error),
        ReleaseTransitionError::Mutation(ReleaseMutationError::Command(error)) => {
            command_error(*error)
        }
        ReleaseTransitionError::Mutation(ReleaseMutationError::Admission(_))
        | ReleaseTransitionError::Unavailable(_) => ErrorSpec::unavailable(
            "release_unavailable",
            "release management is temporarily unavailable",
        ),
        ReleaseTransitionError::Load(_) => ErrorSpec::internal(
            "invalid_release_state",
            "stored release state could not be validated",
        ),
    }
}

fn command_error(error: ReleaseCommandError) -> ErrorSpec {
    match error {
        ReleaseCommandError::NotFound => ErrorSpec::new(
            StatusCode::NOT_FOUND,
            "release_not_found",
            "the release does not exist",
        ),
        ReleaseCommandError::StaleVersion => ErrorSpec::new(
            StatusCode::PRECONDITION_FAILED,
            "stale_release_version",
            "the release changed; inspect its current version before starting a new operation",
        ),
        ReleaseCommandError::InvalidState => ErrorSpec::conflict(
            "release_state_conflict",
            "this action is unavailable in the current release state",
        ),
        ReleaseCommandError::IdempotencyConflict => ErrorSpec::conflict(
            "idempotency_conflict",
            "Idempotency-Key is already bound to a different release command",
        ),
        ReleaseCommandError::InvalidValue => ErrorSpec::bad_request(
            "invalid_release_change",
            "rescheduling requires a future UTC time and a valid resource version",
        ),
        ReleaseCommandError::OutcomeUnknown => ErrorSpec::unavailable(
            "release_outcome_unknown",
            "repeat the identical command or inspect its operation identifier to recover the accepted result",
        ),
    }
}

fn operation_resource(receipt: ReleaseChangeReceipt) -> ReleaseOperationResource {
    ReleaseOperationResource {
        operation_id: receipt.operation_id,
        publication_id: receipt.publication_id,
        version: receipt.version,
        state: release_state(receipt.state),
    }
}

fn release_resource(release: ReleaseView) -> ReleaseResource {
    let publication = release.publication;
    ReleaseResource {
        publication_id: release.publication_id,
        post_id: publication.stable_post_id.as_uuid(),
        preview_digest: wire_preview_digest(&release.accepted_preview_digest),
        revision: publication.pinned_post_digest.to_string().into_boxed_str(),
        state: release_state(publication.state),
        version: publication.version,
        scheduled_for: publication.scheduled_at,
        published_at: publication.published_at,
        block_reason: publication.block_reason.map(|reason| match reason {
            ActivationBlockReason::RevisionUnavailable => ReleaseBlockReason::RevisionUnavailable,
            ActivationBlockReason::PreviewChanged => ReleaseBlockReason::PreviewChanged,
        }),
    }
}

fn release_state(state: CanonicalState) -> ReleaseState {
    match state {
        CanonicalState::Scheduled => ReleaseState::Scheduled,
        CanonicalState::Activating => ReleaseState::Activating,
        CanonicalState::Blocked => ReleaseState::Blocked,
        CanonicalState::Published => ReleaseState::Published,
        CanonicalState::Superseded => ReleaseState::Superseded,
        CanonicalState::Cancelled => ReleaseState::Cancelled,
    }
}

fn invalid_input(request_id: RequestId) -> Response {
    problem(
        ErrorSpec::bad_request(
            "invalid_release_query",
            "release identifiers and cursors must be UUIDs; unknown query fields are rejected",
        ),
        request_id,
    )
}

fn load_problem(error: ReleaseLoadError, request_id: RequestId) -> Response {
    let spec = match error {
        ReleaseLoadError::Database(_) => ErrorSpec::unavailable(
            "release_unavailable",
            "release inspection is temporarily unavailable",
        ),
        ReleaseLoadError::InvalidOperation | ReleaseLoadError::InvalidStoredRelease(_) => {
            ErrorSpec::internal(
                "invalid_release_state",
                "stored release state could not be validated",
            )
        }
    };
    problem(spec, request_id)
}