Skip to main content

lenso_api/
lib.rs

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(listener, app)
102        .with_graceful_shutdown(async move {
103            let mut shutdown_rx = shutdown.subscribe();
104            tokio::select! {
105                () = Shutdown::wait_for_signal() => {},
106                changed = shutdown_rx.changed() => {
107                    let _ = changed;
108                },
109            }
110        })
111        .await?;
112
113    Ok(())
114}
115
116pub fn build_router(ctx: AppContext) -> Router {
117    try_build_router(ctx).expect("Runtime API router should build with a valid composition profile")
118}
119
120pub fn try_build_router(ctx: AppContext) -> platform_core::AppResult<Router> {
121    try_build_router_with_composition(ctx, &lenso_bootstrap::HostComposition::default())
122}
123
124pub fn try_build_router_with_composition(
125    mut ctx: AppContext,
126    composition: &lenso_bootstrap::HostComposition,
127) -> platform_core::AppResult<Router> {
128    if let Some(actor_resolver) =
129        lenso_bootstrap::auth_actor_resolver_for_context_with_composition(&ctx, composition)?
130    {
131        ctx = ctx.with_actor_resolver(actor_resolver);
132    }
133    install_default_platform_admin_catalogs(&ctx, composition)?;
134    let (router, document) =
135        openapi::api_router_for_context_with_composition(&ctx, composition)?.split_for_parts();
136    let document = Arc::new(document);
137    let console_dist_dir = ctx.config.console.dist_dir.clone();
138    let console_index = PathBuf::from(&console_dist_dir).join("index.html");
139
140    Ok(router
141        .route("/docs", axum::routing::get(scalar_docs))
142        .route("/openapi.json", axum::routing::get(serve_openapi))
143        .nest_service(
144            "/console/extensions",
145            ServeDir::new(ctx.config.console.extensions_dir.clone()),
146        )
147        .nest_service(
148            "/console",
149            ServeDir::new(console_dist_dir).fallback(ServeFile::new(console_index)),
150        )
151        .layer(axum::Extension(document))
152        .layer(middleware::from_fn_with_state(
153            ctx.clone(),
154            request_context_middleware,
155        ))
156        .layer(cors_layer(&ctx))
157        .with_state(ctx))
158}
159
160fn install_default_platform_admin_catalogs(
161    ctx: &AppContext,
162    composition: &lenso_bootstrap::HostComposition,
163) -> platform_core::AppResult<()> {
164    lenso_bootstrap::install_default_story_display_catalog_with_composition(ctx, composition)?;
165    platform_admin::install_default_runtime_function_declarations(
166        platform_admin::runtime_function_declarations_from_modules(
167            lenso_bootstrap::linked_runtime_function_declaration_sources_for_context_with_composition(
168                ctx,
169                composition,
170            )?,
171        ),
172    );
173    Ok(())
174}
175
176fn install_admin_module_metadata(metadata: Vec<platform_admin_data::AdminModuleMetadata>) {
177    install_platform_admin_catalogs(&metadata);
178    platform_admin_data::install_admin_module_metadata(metadata);
179}
180
181fn install_platform_admin_catalogs(metadata: &[platform_admin_data::AdminModuleMetadata]) {
182    lenso_bootstrap::install_story_display_catalog(metadata);
183    platform_admin::install_runtime_function_declarations(
184        platform_admin::runtime_function_declarations_from_modules(
185            lenso_bootstrap::runtime_function_declaration_sources_from_metadata(metadata),
186        ),
187    );
188}
189
190async fn scalar_docs() -> Html<&'static str> {
191    Html(SCALAR_DOCS_HTML)
192}
193
194async fn serve_openapi(
195    axum::Extension(document): axum::Extension<Arc<utoipa::openapi::OpenApi>>,
196) -> axum::Json<utoipa::openapi::OpenApi> {
197    axum::Json((*document).clone())
198}
199
200fn cors_layer(ctx: &AppContext) -> CorsLayer {
201    let origins: Vec<HeaderValue> = ctx
202        .config
203        .http
204        .cors_allowed_origins
205        .iter()
206        .filter_map(|origin| origin.parse().ok())
207        .collect();
208
209    CorsLayer::new()
210        .allow_origin(origins)
211        .allow_methods([
212            Method::GET,
213            Method::POST,
214            Method::PUT,
215            Method::PATCH,
216            Method::DELETE,
217            Method::OPTIONS,
218        ])
219        .allow_headers([header::ACCEPT, header::AUTHORIZATION, header::CONTENT_TYPE])
220}
221
222const SCALAR_DOCS_HTML: &str = r##"<!doctype html>
223<html lang="en">
224  <head>
225    <meta charset="utf-8" />
226    <meta name="viewport" content="width=device-width, initial-scale=1" />
227    <title>Lenso API Docs</title>
228    <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
229    <style>
230      body {
231        margin: 0;
232      }
233    </style>
234  </head>
235  <body>
236    <div id="app"></div>
237    <script>
238      Scalar.createApiReference("#app", {
239        url: "/openapi.json",
240        theme: "default",
241      });
242    </script>
243  </body>
244</html>
245"##;