Skip to main content

lenso_api/
lib.rs

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