use std::future::Future;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::net::TcpListener;
use crate::api_error::{ServeError, StartupError};
use crate::config::Config;
use crate::local::LocalRuntime;
use crate::profile::ProfileName;
use crate::routing::Routing;
use crate::{AppState, build_router};
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct ServeOptions {
pub profiles_dir: PathBuf,
pub source: ConfigSource,
}
impl ServeOptions {
#[must_use]
pub fn new(profiles_dir: PathBuf, source: ConfigSource) -> ServeOptions {
ServeOptions {
profiles_dir,
source,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub enum ConfigSource {
Profile(ProfileName),
Path(PathBuf),
}
#[non_exhaustive]
#[derive(Debug, Clone, Default)]
pub struct ProfilesContext {
pub dir: Option<PathBuf>,
pub active: Option<ProfileName>,
}
impl ProfilesContext {
#[must_use]
pub fn new(dir: Option<PathBuf>, active: Option<ProfileName>) -> ProfilesContext {
ProfilesContext { dir, active }
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct Gateway {
state: AppState,
}
impl Gateway {
pub fn from_config(
config: &Config,
profiles: ProfilesContext,
) -> Result<Gateway, StartupError> {
let local = LocalRuntime::start(config).map_err(StartupError::provisioning)?;
let routing = Routing::from_config(config)
.map_err(StartupError::config)?
.merge(local.models().iter().cloned())
.map_err(StartupError::config)?;
let state = AppState::from_parts(
Arc::new(routing),
config.server_key(),
local,
config.web_search_config(),
profiles.dir,
profiles.active.map(|name| name.to_string()),
);
Ok(Gateway { state })
}
pub fn router(&self) -> axum::Router {
build_router(self.state.clone())
}
pub async fn serve(
self,
listener: TcpListener,
shutdown: impl Future<Output = ()> + Send + 'static,
) -> Result<(), ServeError> {
axum::serve(listener, build_router(self.state))
.with_graceful_shutdown(shutdown)
.await
.map_err(ServeError::io)
}
}
pub fn run(options: ServeOptions) -> Result<(), StartupError> {
let (config, active) = load_startup(&options)?;
let bind = config.bind_addr();
let profiles = ProfilesContext::new(Some(options.profiles_dir), active);
let gateway = Gateway::from_config(&config, profiles)?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(StartupError::bind)?;
runtime.block_on(async move {
let listener = TcpListener::bind(bind).await.map_err(StartupError::bind)?;
tracing::info!("promptforge-gateway serving on {bind}");
gateway
.serve(listener, shutdown_signal())
.await
.map_err(StartupError::serve)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ShutdownTrigger {
Interrupted,
HandlerFailed,
}
fn classify_shutdown(result: &std::io::Result<()>) -> ShutdownTrigger {
match result {
Ok(()) => ShutdownTrigger::Interrupted,
Err(_) => ShutdownTrigger::HandlerFailed,
}
}
async fn shutdown_signal() {
match classify_shutdown(&tokio::signal::ctrl_c().await) {
ShutdownTrigger::Interrupted => {
tracing::info!("received Ctrl-C; shutting down gracefully");
}
ShutdownTrigger::HandlerFailed => {
tracing::error!("failed to install Ctrl-C handler; continuing to serve");
std::future::pending::<()>().await;
}
}
}
fn load_startup(options: &ServeOptions) -> Result<(Config, Option<ProfileName>), StartupError> {
match &options.source {
ConfigSource::Profile(name) => {
let config = crate::profile::load_named(&options.profiles_dir, name)
.map_err(StartupError::config)?;
Ok((config, Some(name.clone())))
}
ConfigSource::Path(path) => {
let config = crate::profile::load_path(path).map_err(StartupError::config)?;
let active = path
.file_stem()
.and_then(|stem| stem.to_str())
.and_then(|stem| ProfileName::parse(stem).ok());
Ok((config, active))
}
}
}
#[cfg(test)]
mod tests {
use super::{ShutdownTrigger, classify_shutdown};
#[test]
fn classify_shutdown_distinguishes_interrupt_from_handler_failure() {
assert_eq!(classify_shutdown(&Ok(())), ShutdownTrigger::Interrupted);
assert_eq!(
classify_shutdown(&Err(std::io::Error::other("no handler"))),
ShutdownTrigger::HandlerFailed
);
}
}