foundry-rs 0.4.0

Configuration-driven REST backend library for Rust with PostgreSQL — define schemas, tables, and APIs in JSON, get a production-grade REST service.
Documentation
//! Config ingestion handlers: POST/GET for each config type. X-Tenant-ID is required.

use crate::config::{load_from_pool, resolve};
use crate::db::pool::Pool;
use crate::db::Dialect;
use crate::error::AppError;
use crate::extractors::tenant::TenantId;
use crate::migration::apply_migrations;
use crate::state::AppState;
use crate::store::{
    qualified_sys_table, replace_config_rows, sys_table_for_kind, DEFAULT_PACKAGE_ID,
};
use axum::extract::State;
use axum::Json;
use serde_json::Value;

fn require_tenant(state: &AppState, tenant_id_opt: &Option<String>) -> Result<(), AppError> {
    let tenant_id = tenant_id_opt
        .as_deref()
        .filter(|s| !s.is_empty())
        .ok_or_else(|| AppError::BadRequest("X-Tenant-ID header is required".into()))?;
    state
        .tenant_registry
        .get(tenant_id)
        .ok_or_else(|| AppError::NotFound(format!("tenant not found: {}", tenant_id)))?;
    Ok(())
}

/// Replace config for one kind and package. Returns (body, num_rows_written).
/// When run_migrations is true and num_rows_written > 0, loads full config for that package and runs migrations.
/// When run_migrations is false (e.g. package install), only writes to _sys_*; caller must run migrations once after all kinds are applied.
pub(crate) async fn replace_config(
    pool: &Pool,
    kind: &str,
    body: Vec<Value>,
    run_migrations: bool,
    package_id: &str,
    dialect: Option<&dyn Dialect>,
) -> Result<(Vec<Value>, u64), AppError> {
    let table = sys_table_for_kind(kind)
        .ok_or_else(|| AppError::BadRequest(format!("unknown config kind: {}", kind)))?;
    let mut tx = pool.begin().await?;
    let (count, _version) = replace_config_rows(&mut tx, table, package_id, &body).await?;
    tx.commit().await?;
    if run_migrations && count > 0 {
        let config = load_from_pool(pool, package_id)
            .await
            .map_err(AppError::Config)?;
        apply_migrations(pool, &config, None, None, dialect.unwrap()).await?;
    }
    Ok((body, count))
}

/// Reload in-memory model from DB so new/updated entities are available without restart. Loads config for DEFAULT_PACKAGE_ID.
pub(crate) async fn reload_model(state: &AppState) -> Result<(), AppError> {
    let config = load_from_pool(&state.pool, DEFAULT_PACKAGE_ID)
        .await
        .map_err(AppError::Config)?;
    let new_model = resolve(&config)
        .map_err(AppError::Config)?
        .with_package_id(DEFAULT_PACKAGE_ID);
    let mut guard = state
        .model
        .write()
        .map_err(|_| AppError::BadRequest("state lock".into()))?;
    *guard = new_model;
    Ok(())
}

pub(crate) async fn get_config(
    pool: &Pool,
    kind: &str,
    package_id: &str,
) -> Result<Vec<Value>, AppError> {
    let table = sys_table_for_kind(kind)
        .ok_or_else(|| AppError::BadRequest(format!("unknown config kind: {}", kind)))?;
    let q_table = qualified_sys_table(table);
    let sql = format!(
        "SELECT payload FROM {} WHERE package_id = $1 ORDER BY id",
        q_table
    );
    tracing::debug!(sql = %sql, package_id = %package_id, "query");
    let rows = sqlx::query_scalar::<_, Value>(&sql)
        .bind(package_id)
        .fetch_all(pool)
        .await?;
    Ok(rows)
}

macro_rules! config_handler {
    ($method:ident, $kind:expr) => {
        pub async fn $method(
            TenantId(tenant_id_opt): TenantId,
            State(state): State<AppState>,
            Json(body): Json<Vec<Value>>,
        ) -> Result<impl axum::response::IntoResponse, AppError> {
            require_tenant(&state, &tenant_id_opt)?;
            // When columns are posted directly, reject asset/asset[] types if no storage.
            if $kind == "columns" && state.storage.is_none() {
                use crate::config::types::ColumnConfig;
                use crate::db::{parse_canonical, CanonicalType};
                let asset_cols: Vec<String> = body
                    .iter()
                    .filter_map(|v| serde_json::from_value::<ColumnConfig>(v.clone()).ok())
                    .filter(|c| {
                        matches!(
                            parse_canonical(&c.type_),
                            CanonicalType::Asset | CanonicalType::AssetArray
                        )
                    })
                    .map(|c| c.name)
                    .collect();
                if !asset_cols.is_empty() {
                    return Err(AppError::BadRequest(format!(
                        "Column(s) [{}] use asset type but no storage provider is configured. \
                         Set STORAGE_PROVIDER (s3 | azure | gcs | rustfs) before defining asset columns.",
                        asset_cols.join(", ")
                    )));
                }
            }
            let (out, num_written) = replace_config(
                &state.pool,
                $kind,
                body,
                true,
                DEFAULT_PACKAGE_ID,
                Some(state.dialect.as_ref()),
            )
            .await?;
            if num_written > 0 {
                reload_model(&state).await?;
            }
            let count = out.len() as u64;
            Ok((
                axum::http::StatusCode::OK,
                Json(crate::response::SuccessMany {
                    data: out,
                    meta: crate::response::MetaCount { count },
                }),
            ))
        }
    };
}

macro_rules! get_config_handler {
    ($method:ident, $kind:expr) => {
        pub async fn $method(
            TenantId(tenant_id_opt): TenantId,
            State(state): State<AppState>,
        ) -> Result<impl axum::response::IntoResponse, AppError> {
            require_tenant(&state, &tenant_id_opt)?;
            let out = get_config(&state.pool, $kind, DEFAULT_PACKAGE_ID).await?;
            Ok((
                axum::http::StatusCode::OK,
                Json(crate::response::SuccessMany {
                    data: out.clone(),
                    meta: crate::response::MetaCount {
                        count: out.len() as u64,
                    },
                }),
            ))
        }
    };
}

config_handler!(post_schemas, "schemas");
config_handler!(post_enums, "enums");
config_handler!(post_tables, "tables");
config_handler!(post_columns, "columns");
config_handler!(post_indexes, "indexes");
config_handler!(post_relationships, "relationships");
config_handler!(post_api_entities, "api_entities");
config_handler!(post_kv_stores, "kv_stores");

get_config_handler!(get_schemas, "schemas");
get_config_handler!(get_enums, "enums");
get_config_handler!(get_tables, "tables");
get_config_handler!(get_columns, "columns");
get_config_handler!(get_indexes, "indexes");
get_config_handler!(get_relationships, "relationships");
get_config_handler!(get_api_entities, "api_entities");
get_config_handler!(get_kv_stores, "kv_stores");