1use anyhow::Context as _;
2use axum::Router;
3use axum::http::{HeaderValue, Method, header};
4use axum::middleware;
5use axum::response::Html;
6use platform_core::{
7 AppConfig, AppContext, LoggingEventPublisher, PostgresRuntimeConfigProvider,
8 RuntimeConfigRegistry, Shutdown, connect_pool, telemetry,
9};
10use platform_http::request_context_middleware;
11use std::net::SocketAddr;
12use std::path::PathBuf;
13use std::sync::Arc;
14use tower_http::cors::CorsLayer;
15use tower_http::services::{ServeDir, ServeFile};
16use tracing::info;
17
18pub mod openapi;
19
20pub use openapi::openapi_document;
21
22pub async fn run_from_env() -> anyhow::Result<()> {
23 run_from_env_with_composition(lenso_bootstrap::HostComposition::default()).await
24}
25
26pub async fn run_from_env_with_composition(
27 composition: lenso_bootstrap::HostComposition,
28) -> anyhow::Result<()> {
29 let config = AppConfig::try_from_env().context("invalid application configuration")?;
30 telemetry::init(&config.telemetry)?;
31
32 let db = connect_pool(&config.database).await?;
33 let mut ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
34
35 let descriptors =
36 lenso_bootstrap::runtime_config_descriptors_with_composition(&ctx, &composition)
37 .context("failed to collect runtime-config descriptors")?;
38 let groups =
39 lenso_bootstrap::runtime_config_group_descriptors_with_composition(&ctx, &composition)
40 .context("failed to collect runtime-config groups")?;
41 let registry = RuntimeConfigRegistry::try_new_with_groups(descriptors, groups)
42 .context("duplicate runtime-config descriptor registered")?;
43 platform_admin::install_runtime_config_registry(registry.clone());
44 let runtime_config =
45 PostgresRuntimeConfigProvider::connect(ctx.db.clone(), Arc::new(registry), "api")
46 .await
47 .context("failed to load runtime-config snapshot")?;
48 runtime_config.spawn_listener();
49 ctx = ctx.with_runtime_config_provider(runtime_config);
50
51 let _remote_services = lenso_bootstrap::start_installed_remote_module_services(&ctx)
52 .await
53 .context("failed to start remote module services")?;
54
55 let admin_modules = lenso_bootstrap::load_admin_modules_with_composition(&ctx, &composition)
56 .await
57 .context("failed to load admin modules")?;
58 platform_admin_data::install_admin_modules(admin_modules);
59 let admin_module_metadata =
60 lenso_bootstrap::load_admin_module_metadata_with_composition(&ctx, &composition)
61 .await
62 .context("failed to load admin module metadata")?;
63 install_admin_module_metadata(admin_module_metadata);
64 let remote_http_proxy_registry = lenso_bootstrap::load_remote_http_proxy_registry(&ctx)
65 .await
66 .context("failed to load remote HTTP proxy registry")?;
67 platform_module_remote::install_remote_http_proxy_registry(remote_http_proxy_registry);
68
69 let admin_refresh_ctx = ctx.clone();
70 let admin_refresh_composition = composition.clone();
71 platform_admin_data::install_admin_module_refresh_fn(move || {
72 let ctx = admin_refresh_ctx.clone();
73 let composition = admin_refresh_composition.clone();
74 async move { lenso_bootstrap::load_admin_modules_with_composition(&ctx, &composition).await }
75 });
76 let admin_metadata_refresh_ctx = ctx.clone();
77 let admin_metadata_refresh_composition = composition.clone();
78 platform_admin_data::install_admin_module_metadata_refresh_fn(move || {
79 let ctx = admin_metadata_refresh_ctx.clone();
80 let composition = admin_metadata_refresh_composition.clone();
81 async move {
82 let metadata =
83 lenso_bootstrap::load_admin_module_metadata_with_composition(&ctx, &composition)
84 .await?;
85 install_platform_admin_catalogs(&metadata);
86 Ok(metadata)
87 }
88 });
89
90 let app = try_build_router_with_composition(ctx.clone(), &composition)
91 .context("failed to build API router")?;
92 let address: SocketAddr = format!("{}:{}", ctx.config.http.host, ctx.config.http.port)
93 .parse()
94 .context("invalid HTTP bind address")?;
95
96 info!(%address, "starting API server");
97 let listener = tokio::net::TcpListener::bind(address).await?;
98
99 let shutdown = ctx.shutdown.clone();
100 axum::serve(listener, app)
101 .with_graceful_shutdown(async move {
102 let mut shutdown_rx = shutdown.subscribe();
103 tokio::select! {
104 () = Shutdown::wait_for_signal() => {},
105 changed = shutdown_rx.changed() => {
106 let _ = changed;
107 },
108 }
109 })
110 .await?;
111
112 Ok(())
113}
114
115pub fn build_router(ctx: AppContext) -> Router {
116 try_build_router(ctx).expect("Runtime API router should build with a valid composition profile")
117}
118
119pub fn try_build_router(ctx: AppContext) -> platform_core::AppResult<Router> {
120 try_build_router_with_composition(ctx, &lenso_bootstrap::HostComposition::default())
121}
122
123pub fn try_build_router_with_composition(
124 mut ctx: AppContext,
125 composition: &lenso_bootstrap::HostComposition,
126) -> platform_core::AppResult<Router> {
127 if let Some(actor_resolver) =
128 lenso_bootstrap::auth_actor_resolver_for_context_with_composition(&ctx, composition)?
129 {
130 ctx = ctx.with_actor_resolver(actor_resolver);
131 }
132 install_default_platform_admin_catalogs(&ctx, composition)?;
133 let (router, document) =
134 openapi::api_router_for_context_with_composition(&ctx, composition)?.split_for_parts();
135 let document = Arc::new(document);
136 let console_dist_dir = ctx.config.console.dist_dir.clone();
137 let console_index = PathBuf::from(&console_dist_dir).join("index.html");
138
139 Ok(router
140 .route("/docs", axum::routing::get(scalar_docs))
141 .route("/openapi.json", axum::routing::get(serve_openapi))
142 .nest_service(
143 "/console/extensions",
144 ServeDir::new(ctx.config.console.extensions_dir.clone()),
145 )
146 .nest_service(
147 "/console",
148 ServeDir::new(console_dist_dir).fallback(ServeFile::new(console_index)),
149 )
150 .layer(axum::Extension(document))
151 .layer(middleware::from_fn_with_state(
152 ctx.clone(),
153 request_context_middleware,
154 ))
155 .layer(cors_layer(&ctx))
156 .with_state(ctx))
157}
158
159fn install_default_platform_admin_catalogs(
160 ctx: &AppContext,
161 composition: &lenso_bootstrap::HostComposition,
162) -> platform_core::AppResult<()> {
163 lenso_bootstrap::install_default_story_display_catalog_with_composition(ctx, composition)?;
164 platform_admin::install_default_runtime_function_declarations(
165 platform_admin::runtime_function_declarations_from_modules(
166 lenso_bootstrap::linked_runtime_function_declaration_sources_for_context_with_composition(
167 ctx,
168 composition,
169 )?,
170 ),
171 );
172 Ok(())
173}
174
175fn install_admin_module_metadata(metadata: Vec<platform_admin_data::AdminModuleMetadata>) {
176 install_platform_admin_catalogs(&metadata);
177 platform_admin_data::install_admin_module_metadata(metadata);
178}
179
180fn install_platform_admin_catalogs(metadata: &[platform_admin_data::AdminModuleMetadata]) {
181 lenso_bootstrap::install_story_display_catalog(metadata);
182 platform_admin::install_runtime_function_declarations(
183 platform_admin::runtime_function_declarations_from_modules(
184 lenso_bootstrap::runtime_function_declaration_sources_from_metadata(metadata),
185 ),
186 );
187}
188
189async fn scalar_docs() -> Html<&'static str> {
190 Html(SCALAR_DOCS_HTML)
191}
192
193async fn serve_openapi(
194 axum::Extension(document): axum::Extension<Arc<utoipa::openapi::OpenApi>>,
195) -> axum::Json<utoipa::openapi::OpenApi> {
196 axum::Json((*document).clone())
197}
198
199fn cors_layer(ctx: &AppContext) -> CorsLayer {
200 let origins: Vec<HeaderValue> = ctx
201 .config
202 .http
203 .cors_allowed_origins
204 .iter()
205 .filter_map(|origin| origin.parse().ok())
206 .collect();
207
208 CorsLayer::new()
209 .allow_origin(origins)
210 .allow_methods([
211 Method::GET,
212 Method::POST,
213 Method::PUT,
214 Method::PATCH,
215 Method::DELETE,
216 Method::OPTIONS,
217 ])
218 .allow_headers([header::ACCEPT, header::AUTHORIZATION, header::CONTENT_TYPE])
219}
220
221const SCALAR_DOCS_HTML: &str = r##"<!doctype html>
222<html lang="en">
223 <head>
224 <meta charset="utf-8" />
225 <meta name="viewport" content="width=device-width, initial-scale=1" />
226 <title>Lenso API Docs</title>
227 <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
228 <style>
229 body {
230 margin: 0;
231 }
232 </style>
233 </head>
234 <body>
235 <div id="app"></div>
236 <script>
237 Scalar.createApiReference("#app", {
238 url: "/openapi.json",
239 theme: "default",
240 });
241 </script>
242 </body>
243</html>
244"##;