ordinary-api 0.6.0-pre.13

API server for Ordinary
Documentation
// Copyright (C) 2026 Ordinary Labs, LLC.
//
// SPDX-License-Identifier: AGPL-3.0-only

use crate::client::{OrdinaryApiClient, compress_zstd};
use anyhow::bail;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as b64};
use hashbrown::HashSet;
use hyper::StatusCode;
use ordinary_config::{OrdinaryApiLimits, OrdinaryConfig};
use ordinary_monitor::client::{IndexDbs, OrdinaryMonitorClient};
use ordinary_types::flexbuffer_reader_to_json;
use reqwest::Response;
use serde_json::Value;
use uuid::Uuid;

fn set_contacts(app_config: &mut OrdinaryConfig) -> anyhow::Result<()> {
    if app_config.contacts.is_none() || app_config.contacts.as_ref().is_some_and(Vec::is_empty) {
        if let Ok(contacts) = std::env::var("ORDINARY_CONTACTS") {
            app_config.contacts = Some(
                contacts
                    .split(',')
                    .map(ToString::to_string)
                    .collect::<Vec<String>>(),
            );
        } else {
            bail!(
                r#""contacts" array is empty in `ordinary.json` and no `ORDINARY_CONTACTS` environment variable is present.
add email address(es) to "contacts" array or [comma delimited] to `ORDINARY_CONTACTS` (e.g. ORDINARY_CONTACTS=qwer@example.com,asdf@example.com)."#
            );
        }
    }

    Ok(())
}

async fn check_limits(
    api_client: &OrdinaryApiClient<'_>,
    correlation_id: &str,
    app_config: &mut OrdinaryConfig,
) -> anyhow::Result<()> {
    let api_limits: OrdinaryApiLimits = api_client
        .client
        .get(format!("{}/.ordinary/limits", api_client.addr))
        .header("x-correlation-id", correlation_id)
        .send()
        .await?
        .json()
        .await?;

    let privileged_domains = api_limits
        .privileged_domains
        .iter()
        .cloned()
        .collect::<HashSet<_>>();
    app_config.check_config_against_limits(&api_limits, &privileged_domains)?;
    Ok(())
}

async fn check_port(res: Response) -> anyhow::Result<u16> {
    let status = res.status();
    let res = res.bytes().await?;

    if res.len() == 2 {
        let port = u16::from_be_bytes([res[0], res[1]]);

        match status {
            StatusCode::OK => {
                if port > 0 {
                    tracing::info!("success. running on port: {port}");
                } else {
                    tracing::info!("success");
                }
            }
            _ => {
                if port > 0 {
                    tracing::warn!("operation invalid. status: {status}, running on port: {port}");
                } else {
                    tracing::warn!("operation invalid. status: {status}");
                }
            }
        }

        Ok(port)
    } else if !res.is_empty() {
        let message = std::str::from_utf8(&res)?;
        bail!("operation failed. status: {status}, message: {message}")
    } else {
        bail!("operation failed. status: {status}")
    }
}

pub async fn deploy(api_client: &OrdinaryApiClient<'_>, proj_path: &str) -> anyhow::Result<u16> {
    let correlation_id = api_client
        .correlation_id
        .unwrap_or(Uuid::new_v4())
        .to_string();

    let mut app_config = OrdinaryConfig::get(proj_path)?.for_send()?;
    app_config.validate()?;

    set_contacts(&mut app_config)?;
    check_limits(api_client, &correlation_id, &mut app_config).await?;

    let access_token = api_client
        .get_access(None, Some(correlation_id.clone()))
        .await?;

    tracing::info!("deploying app...");

    let res = api_client
        .client
        .put(format!("{}/v1/app/deploy", api_client.addr))
        .body(compress_zstd(
            serde_json::to_string(&app_config)?.as_bytes(),
        )?)
        .header("x-correlation-id", correlation_id)
        .header("Content-Encoding", "zstd")
        .header(
            "Authorization",
            format!("Bearer {}", b64.encode(access_token)),
        )
        .header("Content-Type", "application/json")
        .send()
        .await?;

    check_port(res).await
}

pub async fn kill(api_client: &OrdinaryApiClient<'_>, proj_path: &str) -> anyhow::Result<()> {
    let correlation_id = api_client.correlation_id.map(|id| id.to_string());

    let config = OrdinaryConfig::get(proj_path)?;

    let access_token = api_client.get_access(None, correlation_id.clone()).await?;

    tracing::info!("killing app...");
    let mut req = api_client
        .client
        .put(format!("{}/v1/app/kill", api_client.addr))
        .query(&[("d", config.domain)])
        .header("Content-Encoding", "zstd")
        .header(
            "Authorization",
            format!("Bearer {}", b64.encode(access_token)),
        );

    if let Some(correlation_id) = correlation_id {
        req = req.header("x-correlation-id", correlation_id);
    }

    let res = req.send().await?;

    if res.status().is_success() {
        tracing::info!("app killed.");
        Ok(())
    } else {
        bail!("{}", res.status())
    }
}

pub async fn restart(api_client: &OrdinaryApiClient<'_>, proj_path: &str) -> anyhow::Result<u16> {
    let correlation_id = api_client.correlation_id.map(|id| id.to_string());

    let config = OrdinaryConfig::get(proj_path)?;

    let access_token = api_client.get_access(None, correlation_id.clone()).await?;

    tracing::info!("restarting app...");
    let mut req = api_client
        .client
        .post(format!("{}/v1/app/restart", api_client.addr))
        .query(&[("d", config.domain)])
        .header("Content-Encoding", "zstd")
        .header(
            "Authorization",
            format!("Bearer {}", b64.encode(access_token)),
        );

    if let Some(correlation_id) = correlation_id {
        req = req.header("x-correlation-id", correlation_id);
    }

    let res = req.send().await?;

    check_port(res).await
}

pub async fn erase(api_client: &OrdinaryApiClient<'_>, proj_path: &str) -> anyhow::Result<()> {
    let correlation_id = api_client.correlation_id.map(|id| id.to_string());

    let config = OrdinaryConfig::get(proj_path)?;

    let access_token = api_client.get_access(None, correlation_id.clone()).await?;

    tracing::info!("erasing app...");
    let mut req = api_client
        .client
        .delete(format!("{}/v1/app/erase", api_client.addr))
        .query(&[("d", config.domain)])
        .header("Content-Encoding", "zstd")
        .header(
            "Authorization",
            format!("Bearer {}", b64.encode(access_token)),
        );

    if let Some(correlation_id) = correlation_id {
        req = req.header("x-correlation-id", correlation_id);
    }

    let res = req.send().await?;

    if res.status().is_success() {
        tracing::info!("app erased.");
        Ok(())
    } else {
        bail!("{}", res.status())
    }
}

#[allow(clippy::missing_panics_doc, clippy::ref_option)]
pub fn logs_search(
    proj_path: &str,
    query: &str,
    format: &str,
    limit: &Option<usize>,
) -> anyhow::Result<String> {
    let config = OrdinaryConfig::get(proj_path)?;
    let monitor_client = OrdinaryMonitorClient::new(&config.domain, vec![IndexDbs::Tantivy])?;

    match format {
        "all" => monitor_client.tantivy_search_all(query),
        "top" => monitor_client.tantivy_search_top(query, limit),
        "count" => Ok(monitor_client.tantivy_search_count(query)?.to_string()),
        _ => bail!("invalid format '{format}' -- try 'all', 'top' or 'count'"),
    }
}

pub async fn accounts_list(
    api_client: &OrdinaryApiClient<'_>,
    proj_path: &str,
) -> anyhow::Result<String> {
    let correlation_id = api_client.correlation_id.map(|id| id.to_string());

    let config = OrdinaryConfig::get(proj_path)?;

    let access_token = api_client.get_access(None, correlation_id.clone()).await?;

    tracing::info!("fetching...");
    let mut req = api_client
        .client
        .get(format!("{}/v1/app/accounts", api_client.addr))
        .query(&[("d", config.domain)])
        .header(
            "Authorization",
            format!("Bearer {}", b64.encode(access_token)),
        );

    if let Some(correlation_id) = correlation_id {
        req = req.header("x-correlation-id", correlation_id);
    }

    let res = req.send().await?.bytes().await?;

    let root = flexbuffers::Reader::get_root(&res[..])?;

    let mut out = vec![];

    for account in &root.as_vector() {
        let mut account_out = vec![];

        let account_name_bytes = account
            .as_vector()
            .idx(0)
            .as_vector()
            .iter()
            .map(|v| v.as_u8())
            .collect::<Vec<u8>>();

        let account_name = std::str::from_utf8(&account_name_bytes)?;
        account_out.push(Value::from(account_name));

        if let Some(auth_config) = &config.auth {
            for claim_field in &auth_config.access_token.claims {
                let json_val = flexbuffer_reader_to_json(
                    &claim_field.kind,
                    &account.as_vector().idx(claim_field.idx as usize),
                )?;
                account_out.push(json_val);
            }
        }

        out.push(account_out);
    }

    tracing::info!("done.");

    Ok(serde_json::to_string(&out)?)
}