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