Skip to content
//! Application routes: inventory, lifecycle, and image updates.
//!
//! Every mutating handler resolves the `{host}` path segment against the live
//! Docker inventory first, so a job is only ever submitted for an application
//! that actually exists.

use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use twice_core::{AppsResponse, Host, InfrastructureSpec, JobKind, JobView, UpdateImageRequest};

use crate::error::ApiError;
use crate::state::AppState;

/// `GET /apps` โ€” every `once`-managed application on this host.
pub(crate) async fn list(State(state): State<AppState>) -> Result<Json<AppsResponse>, ApiError> {
    Ok(Json(AppsResponse {
        apps: state.docker.list_apps().await?,
    }))
}

/// `POST /apps/{host}/start`
pub(crate) async fn start(
    State(state): State<AppState>,
    Path(host): Path<String>,
) -> Result<(StatusCode, Json<JobView>), ApiError> {
    let host = resolve(&state, &host).await?;
    submit(&state, JobKind::Start { host }).await
}

/// `POST /apps/{host}/stop`
pub(crate) async fn stop(
    State(state): State<AppState>,
    Path(host): Path<String>,
) -> Result<(StatusCode, Json<JobView>), ApiError> {
    let host = resolve(&state, &host).await?;
    submit(&state, JobKind::Stop { host }).await
}

/// `POST /apps/{host}/update` โ€” roll an application onto a new image.
pub(crate) async fn update_image(
    State(state): State<AppState>,
    Path(host): Path<String>,
    Json(body): Json<UpdateImageRequest>,
) -> Result<(StatusCode, Json<JobView>), ApiError> {
    let host = resolve(&state, &host).await?;
    let kind = JobKind::UpdateImage {
        host,
        image: body.image,
        auto_update: body.auto_update,
    };
    submit(&state, kind).await
}

/// `POST /infrastructure/apply` โ€” converge one declared application.
///
/// A missing host is deployed; an existing host is rolled onto the declared
/// image. The same document therefore works for initial creation and updates.
pub(crate) async fn apply_infrastructure(
    State(state): State<AppState>,
    Json(spec): Json<InfrastructureSpec>,
) -> Result<(StatusCode, Json<JobView>), ApiError> {
    let known = state.docker.knows_host(&spec.host).await?;
    let kind = infrastructure_job(known, spec);
    submit(&state, kind).await
}

fn infrastructure_job(known: bool, spec: InfrastructureSpec) -> JobKind {
    let InfrastructureSpec {
        host,
        source,
        version,
    } = spec;
    let image = source.into_image(&version);
    if known {
        JobKind::UpdateImage {
            host,
            image,
            auto_update: None,
        }
    } else {
        JobKind::Deploy { host, image }
    }
}

/// Parses the path segment and confirms the application exists.
async fn resolve(state: &AppState, raw: &str) -> Result<Host, ApiError> {
    let host = Host::parse(raw).map_err(|err| ApiError::BadHost(raw.to_owned(), err))?;
    if state.docker.knows_host(&host).await? {
        Ok(host)
    } else {
        Err(ApiError::UnknownApp(host))
    }
}

async fn submit(state: &AppState, kind: JobKind) -> Result<(StatusCode, Json<JobView>), ApiError> {
    let view = state.jobs.submit(kind).await?;
    Ok((StatusCode::ACCEPTED, Json(view)))
}

#[cfg(test)]
mod tests {
    use twice_core::{ImageSource, ImageVersion};

    use super::*;

    fn spec() -> InfrastructureSpec {
        InfrastructureSpec {
            host: Host::parse("writebook.example.com").unwrap(),
            source: ImageSource::parse("ghcr.io/basecamp/writebook").unwrap(),
            version: ImageVersion::parse("1.4.0").unwrap(),
        }
    }

    #[test]
    fn missing_application_is_deployed_from_the_pinned_image() {
        match infrastructure_job(false, spec()) {
            JobKind::Deploy { host, image } => {
                assert_eq!(host.as_str(), "writebook.example.com");
                assert_eq!(image.as_str(), "ghcr.io/basecamp/writebook:1.4.0");
            }
            other => panic!("expected deploy, got {other:?}"),
        }
    }

    #[test]
    fn existing_application_is_updated_from_the_same_document() {
        match infrastructure_job(true, spec()) {
            JobKind::UpdateImage {
                image, auto_update, ..
            } => {
                assert_eq!(image.as_str(), "ghcr.io/basecamp/writebook:1.4.0");
                assert_eq!(auto_update, None);
            }
            other => panic!("expected update, got {other:?}"),
        }
    }
}