//! The `{{ app }}` app: pages rendered on the server from the templates in
//! `templates/` ({{ engine_name }}, see [`views`]), served with Axum.
//!
//! It is part of the project's workspace and can be routed by the gateway
//! (see `config/apps.toml`). Business data comes from the project's MCP
//! backend through [`mcp::McpClient`]; the app never touches the database.
//! Users sign in at the OpenID Connect provider (see `pages.rs`).
mod mcp;
mod pages;
mod views;
use std::process::ExitCode;
use std::path::PathBuf;
use std::sync::Arc;
use slave_framework::env;
use slave_framework::oidc::OidcClient;
use tokio::sync::OnceCell;
/// The app's name, shown by the templates as `app`.
pub const TITLE: &str = "{{ title }}";
/// Port used when `PORT` is not set; `slave serve` sets it from `config/apps.toml`.
const DEFAULT_PORT: u16 = {{ port }};
/// State shared by every page.
#[derive(Clone)]
pub struct AppState {
pub mcp: mcp::McpClient,
/// Path prefix the app is served under, e.g. `/admin`, or `""` at the root.
pub base: String,
pub sign_in: Arc<SignInSettings>,
/// The templates, loaded at start-up (and reloaded per request in debug
/// builds, so template edits show without restarting).
pub views: Arc<views::Views>,
pub templates_dir: Arc<PathBuf>,
/// The OpenID Connect client, discovered on first sign-in.
oidc: Arc<OnceCell<OidcClient>>,
}
/// How this app signs users in at the OpenID Connect provider.
pub struct SignInSettings {
/// `OIDC_ISSUER_URL`, shared with the backend.
pub issuer: Option<String>,
/// `{{ env }}_OIDC_CLIENT_ID`: this app's client ID at the provider (also
/// listed in the backend's `OIDC_AUDIENCES`).
pub client_id: Option<String>,
/// `{{ env }}_OIDC_CLIENT_SECRET`, if the provider gave this app one.
pub client_secret: Option<String>,
/// `{{ env }}_PUBLIC_URL`, e.g. `https://shop.example.com`; otherwise the
/// callback URL is derived from the request (and the gateway's headers).
pub public_url: Option<String>,
}
impl SignInSettings {
fn from_env() -> Self {
let var = |key: &str| {
std::env::var(key)
.ok()
.filter(|value| !value.trim().is_empty())
};
Self {
issuer: var("OIDC_ISSUER_URL"),
client_id: var("{{ env }}_OIDC_CLIENT_ID"),
client_secret: var("{{ env }}_OIDC_CLIENT_SECRET"),
public_url: var("{{ env }}_PUBLIC_URL"),
}
}
}
impl AppState {
/// The OpenID Connect client, or why sign-in is unavailable.
pub async fn oidc(&self) -> Result<&OidcClient, String> {
let settings = &self.sign_in;
let (Some(issuer), Some(client_id)) = (&settings.issuer, &settings.client_id) else {
return Err(
"Sign-in is not configured: set OIDC_ISSUER_URL and {{ env }}_OIDC_CLIENT_ID in .env."
.to_owned(),
);
};
self.oidc
.get_or_try_init(|| {
OidcClient::discover(issuer, client_id, settings.client_secret.as_deref())
})
.await
.map_err(|err| {
tracing::warn!("{err}");
"The sign-in provider is not reachable right now.".to_owned()
})
}
/// `path` (starting with `/`) under the app's base path.
pub fn url(&self, path: &str) -> String {
match (self.base.as_str(), path) {
("", path) => path.to_owned(),
(base, "/") => base.to_owned(),
(base, path) => format!("{base}{path}"),
}
}
}
#[tokio::main]
async fn main() -> ExitCode {
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err}");
ExitCode::FAILURE
}
}
}
async fn run() -> slave_framework::Result<()> {
env::load_dotenv()?;
slave_framework::logging::init()?;
let host = env::var_or("HOST", "127.0.0.1");
let port = env::parse_or("PORT", DEFAULT_PORT)?;
let mcp = mcp::McpClient::new(mcp_url());
tracing::info!("{{ app }} uses the MCP backend at {}", mcp.url());
let templates_dir = templates_dir();
let views = views::Views::load(&templates_dir).map_err(|err| {
slave_framework::Error::Server(std::io::Error::other(format!(
"templates in {}: {err}",
templates_dir.display()
)))
})?;
let state = AppState {
mcp,
views: Arc::new(views),
templates_dir: Arc::new(templates_dir),
base: base_path(),
sign_in: Arc::new(SignInSettings::from_env()),
oidc: Arc::new(OnceCell::new()),
};
slave_framework::server::serve(pages::router(state), &host, port).await
}
/// `{{ env }}_TEMPLATES_DIR`, or this app's `templates/` directory.
fn templates_dir() -> PathBuf {
std::env::var("{{ env }}_TEMPLATES_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/templates")))
}
/// `SLAVE_BASE_PATH` (set when the gateway routes a path prefix such as
/// `/admin` to this app), normalized to `/admin`, or `""` for the root.
fn base_path() -> String {
let base = env::var_or("SLAVE_BASE_PATH", "");
let base = base.trim_matches('/');
if base.is_empty() {
String::new()
} else {
format!("/{base}")
}
}
/// `SLAVE_MCP_URL`, or the backend's `APP_HOST:APP_PORT` from `.env`.
fn mcp_url() -> String {
std::env::var("SLAVE_MCP_URL").unwrap_or_else(|_| {
let host = match env::var_or("APP_HOST", "127.0.0.1").as_str() {
"0.0.0.0" | "" => "127.0.0.1".to_owned(),
host => host.to_owned(),
};
let port = env::var_or("APP_PORT", "3000");
format!("http://{host}:{port}/mcp")
})
}