1use platform_core::{AppError, ErrorCode, RequestContext, StoryDisplayDescriptor};
7use platform_http::{ApiErrorResponse, ApiOpenApiRouter, OpenApiRouter, routes};
8use platform_module::{
9 AdminActionSource, AdminDataSource, AdminQuerySource, AdminSchema, AdminSurface,
10 ConsoleSurface, EventSurface, LifecycleSurface, ModuleHttpRoute, ModuleLoadStatus,
11 ModuleSource, RuntimeSurface,
12};
13use std::sync::{Arc, OnceLock, RwLock};
14use std::time::Instant;
15
16mod dto;
17mod handlers;
18
19pub use dto::*;
20#[allow(clippy::wildcard_imports)]
21use handlers::*;
22
23#[derive(Clone, Debug)]
26pub struct AdminModule {
27 pub module_name: String,
29 pub source: ModuleSource,
31 pub load_status: ModuleLoadStatus,
34 pub schema: AdminSchema,
36 pub admin: Option<AdminSurface>,
38 pub listed_in_schema: bool,
42 pub data_source: Option<Arc<dyn AdminDataSource>>,
45 pub action_source: Option<Arc<dyn AdminActionSource>>,
47 pub query_source: Option<Arc<dyn AdminQuerySource>>,
49}
50
51#[derive(Clone, Debug)]
54pub struct AdminModuleMetadata {
55 pub module_name: String,
57 pub source: ModuleSource,
59 pub load_status: ModuleLoadStatus,
61 pub http_routes: Vec<ModuleHttpRoute>,
64 pub runtime: Option<RuntimeSurface>,
67 pub events: Option<EventSurface>,
70 pub lifecycle: Option<LifecycleSurface>,
73 pub console: Vec<ConsoleSurface>,
75 pub story_display: Vec<StoryDisplayDescriptor>,
78 pub capabilities: Vec<String>,
80 pub dependencies: Vec<String>,
82 pub admin: Option<AdminSurface>,
85 pub source_diagnostics: Option<AdminModuleSourceDiagnostics>,
88}
89
90#[derive(Clone, Debug)]
91pub enum AdminModuleSourceDiagnostics {
92 Remote(AdminRemoteModuleDiagnostics),
93}
94
95#[derive(Clone, Debug)]
96pub struct AdminRemoteModuleDiagnostics {
97 pub transport: String,
98 pub base_url: String,
99 pub manifest_url: String,
100 pub timeout_ms: u64,
101 pub auth_configured: bool,
102 pub load_duration_ms: Option<u64>,
103 pub last_checked_at: Option<String>,
104 pub last_load_error: Option<String>,
105}
106
107#[derive(Clone, Debug, Default)]
108struct AdminModuleMetadataSnapshot {
109 modules: Vec<AdminModuleMetadata>,
110 refreshed_at: Option<String>,
111 refresh_error: Option<String>,
112 refresh_history: Vec<AdminModuleMetadataRefreshRecord>,
113}
114
115#[derive(Clone, Debug)]
116pub struct AdminModuleMetadataRefreshRecord {
117 pub id: String,
118 pub status: AdminModuleMetadataRefreshStatus,
119 pub started_at: String,
120 pub completed_at: String,
121 pub duration_ms: u64,
122 pub module_count: usize,
123 pub error: Option<String>,
124 pub module_results: Vec<AdminModuleMetadataRefreshModuleResult>,
125}
126
127#[derive(Clone, Debug)]
128pub struct AdminModuleMetadataRefreshModuleResult {
129 pub module_name: String,
130 pub source: ModuleSource,
131 pub status: AdminModuleMetadataRefreshModuleStatus,
132 pub duration_ms: Option<u64>,
133 pub endpoint: Option<String>,
134 pub error: Option<String>,
135}
136
137#[derive(Clone, Copy, Debug)]
138pub enum AdminModuleMetadataRefreshModuleStatus {
139 Loaded,
140 Error,
141}
142
143#[derive(Clone, Copy, Debug)]
144pub enum AdminModuleMetadataRefreshStatus {
145 Success,
146 Error,
147}
148
149static ADMIN_REGISTRY: OnceLock<RwLock<Vec<AdminModule>>> = OnceLock::new();
150static ADMIN_METADATA_REGISTRY: OnceLock<RwLock<AdminModuleMetadataSnapshot>> = OnceLock::new();
151static ADMIN_REFRESHER: OnceLock<RwLock<Option<Arc<dyn AdminModuleRefresher>>>> = OnceLock::new();
152static ADMIN_METADATA_REFRESHER: OnceLock<RwLock<Option<Arc<dyn AdminModuleMetadataRefresher>>>> =
153 OnceLock::new();
154
155#[async_trait::async_trait]
156pub trait AdminModuleRefresher: Send + Sync {
157 async fn refresh_admin_modules(&self) -> platform_core::AppResult<Vec<AdminModule>>;
158}
159
160#[async_trait::async_trait]
161pub trait AdminModuleMetadataRefresher: Send + Sync {
162 async fn refresh_admin_module_metadata(
163 &self,
164 ) -> platform_core::AppResult<Vec<AdminModuleMetadata>>;
165}
166
167struct StaticAdminModuleRefresher<F>(F);
168struct StaticAdminModuleMetadataRefresher<F>(F);
169
170#[async_trait::async_trait]
171impl<F, Fut> AdminModuleRefresher for StaticAdminModuleRefresher<F>
172where
173 F: Fn() -> Fut + Send + Sync,
174 Fut: std::future::Future<Output = platform_core::AppResult<Vec<AdminModule>>> + Send,
175{
176 async fn refresh_admin_modules(&self) -> platform_core::AppResult<Vec<AdminModule>> {
177 (self.0)().await
178 }
179}
180
181#[async_trait::async_trait]
182impl<F, Fut> AdminModuleMetadataRefresher for StaticAdminModuleMetadataRefresher<F>
183where
184 F: Fn() -> Fut + Send + Sync,
185 Fut: std::future::Future<Output = platform_core::AppResult<Vec<AdminModuleMetadata>>> + Send,
186{
187 async fn refresh_admin_module_metadata(
188 &self,
189 ) -> platform_core::AppResult<Vec<AdminModuleMetadata>> {
190 (self.0)().await
191 }
192}
193
194pub fn install_admin_modules(modules: Vec<AdminModule>) {
198 let registry = ADMIN_REGISTRY.get_or_init(|| RwLock::new(Vec::new()));
199 *registry.write().expect("admin registry lock poisoned") = modules;
200}
201
202pub fn install_admin_module_metadata(modules: Vec<AdminModuleMetadata>) {
204 let registry =
205 ADMIN_METADATA_REGISTRY.get_or_init(|| RwLock::new(AdminModuleMetadataSnapshot::default()));
206 *registry
207 .write()
208 .expect("admin metadata registry lock poisoned") = AdminModuleMetadataSnapshot {
209 modules,
210 refreshed_at: Some(current_timestamp()),
211 refresh_error: None,
212 refresh_history: Vec::new(),
213 };
214}
215
216pub(crate) fn record_admin_module_metadata_refresh_success(
217 modules: Vec<AdminModuleMetadata>,
218 started_at: String,
219 started: Instant,
220) -> AdminModuleMetadataSnapshot {
221 let registry =
222 ADMIN_METADATA_REGISTRY.get_or_init(|| RwLock::new(AdminModuleMetadataSnapshot::default()));
223 let mut snapshot = registry
224 .write()
225 .expect("admin metadata registry lock poisoned");
226 let completed_at = current_timestamp();
227 let record = AdminModuleMetadataRefreshRecord {
228 id: format!(
229 "module_refresh_{}",
230 completed_at.replace([':', '.', '+'], "_")
231 ),
232 status: AdminModuleMetadataRefreshStatus::Success,
233 started_at,
234 completed_at: completed_at.clone(),
235 duration_ms: duration_ms(started),
236 module_count: modules.len(),
237 error: None,
238 module_results: refresh_module_results(&modules),
239 };
240 snapshot.modules = modules;
241 snapshot.refreshed_at = Some(completed_at);
242 snapshot.refresh_error = None;
243 push_refresh_record(&mut snapshot.refresh_history, record);
244 snapshot.clone()
245}
246
247pub fn install_admin_module_refresher(refresher: Arc<dyn AdminModuleRefresher>) {
252 let registry = ADMIN_REFRESHER.get_or_init(|| RwLock::new(None));
253 *registry.write().expect("admin refresher lock poisoned") = Some(refresher);
254}
255
256pub fn install_admin_module_refresh_fn<F, Fut>(refresh: F)
257where
258 F: Fn() -> Fut + Send + Sync + 'static,
259 Fut: std::future::Future<Output = platform_core::AppResult<Vec<AdminModule>>> + Send + 'static,
260{
261 install_admin_module_refresher(Arc::new(StaticAdminModuleRefresher(refresh)));
262}
263
264pub fn install_admin_module_metadata_refresher(refresher: Arc<dyn AdminModuleMetadataRefresher>) {
266 let registry = ADMIN_METADATA_REFRESHER.get_or_init(|| RwLock::new(None));
267 *registry
268 .write()
269 .expect("admin metadata refresher lock poisoned") = Some(refresher);
270}
271
272pub fn install_admin_module_metadata_refresh_fn<F, Fut>(refresh: F)
273where
274 F: Fn() -> Fut + Send + Sync + 'static,
275 Fut: std::future::Future<Output = platform_core::AppResult<Vec<AdminModuleMetadata>>>
276 + Send
277 + 'static,
278{
279 install_admin_module_metadata_refresher(Arc::new(StaticAdminModuleMetadataRefresher(refresh)));
280}
281
282fn admin_modules() -> Vec<AdminModule> {
283 ADMIN_REGISTRY
284 .get()
285 .map(|registry| {
286 registry
287 .read()
288 .expect("admin registry lock poisoned")
289 .clone()
290 })
291 .unwrap_or_default()
292}
293
294fn admin_module_metadata_snapshot() -> AdminModuleMetadataSnapshot {
295 ADMIN_METADATA_REGISTRY
296 .get()
297 .map(|registry| {
298 registry
299 .read()
300 .expect("admin metadata registry lock poisoned")
301 .clone()
302 })
303 .unwrap_or_default()
304}
305
306pub(crate) fn record_admin_module_metadata_refresh_error(
307 error: String,
308 started_at: String,
309 started: Instant,
310) -> AdminModuleMetadataSnapshot {
311 let registry =
312 ADMIN_METADATA_REGISTRY.get_or_init(|| RwLock::new(AdminModuleMetadataSnapshot::default()));
313 let mut snapshot = registry
314 .write()
315 .expect("admin metadata registry lock poisoned");
316 snapshot.refresh_error = Some(error);
317 let completed_at = current_timestamp();
318 let record = AdminModuleMetadataRefreshRecord {
319 id: format!(
320 "module_refresh_{}",
321 completed_at.replace([':', '.', '+'], "_")
322 ),
323 status: AdminModuleMetadataRefreshStatus::Error,
324 started_at,
325 completed_at,
326 duration_ms: duration_ms(started),
327 module_count: snapshot.modules.len(),
328 error: snapshot.refresh_error.clone(),
329 module_results: Vec::new(),
330 };
331 push_refresh_record(&mut snapshot.refresh_history, record);
332 snapshot.clone()
333}
334
335pub(crate) fn current_timestamp() -> String {
336 use platform_core::Clock;
337 platform_core::SystemClock.now().to_rfc3339()
338}
339
340fn duration_ms(started: Instant) -> u64 {
341 u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
342}
343
344fn push_refresh_record(
345 history: &mut Vec<AdminModuleMetadataRefreshRecord>,
346 record: AdminModuleMetadataRefreshRecord,
347) {
348 history.insert(0, record);
349 history.truncate(10);
350}
351
352fn refresh_module_results(
353 modules: &[AdminModuleMetadata],
354) -> Vec<AdminModuleMetadataRefreshModuleResult> {
355 modules
356 .iter()
357 .map(|module| {
358 let remote = match &module.source_diagnostics {
359 Some(AdminModuleSourceDiagnostics::Remote(remote)) => Some(remote),
360 None => None,
361 };
362 AdminModuleMetadataRefreshModuleResult {
363 module_name: module.module_name.clone(),
364 source: module.source,
365 status: match module.load_status {
366 ModuleLoadStatus::Loaded => AdminModuleMetadataRefreshModuleStatus::Loaded,
367 ModuleLoadStatus::Error { .. } => AdminModuleMetadataRefreshModuleStatus::Error,
368 },
369 duration_ms: remote.and_then(|diagnostics| diagnostics.load_duration_ms),
370 endpoint: remote.map(|diagnostics| diagnostics.base_url.clone()),
371 error: match &module.load_status {
372 ModuleLoadStatus::Loaded => {
373 remote.and_then(|diagnostics| diagnostics.last_load_error.clone())
374 }
375 ModuleLoadStatus::Error { message } => Some(message.clone()),
376 },
377 }
378 })
379 .collect()
380}
381
382fn admin_refresher() -> Option<Arc<dyn AdminModuleRefresher>> {
383 ADMIN_REFRESHER.get().and_then(|registry| {
384 registry
385 .read()
386 .expect("admin refresher lock poisoned")
387 .clone()
388 })
389}
390
391fn admin_metadata_refresher() -> Option<Arc<dyn AdminModuleMetadataRefresher>> {
392 ADMIN_METADATA_REFRESHER.get().and_then(|registry| {
393 registry
394 .read()
395 .expect("admin metadata refresher lock poisoned")
396 .clone()
397 })
398}
399
400fn find_module(module: &str, ctx: &RequestContext) -> Result<AdminModule, ApiErrorResponse> {
401 admin_modules()
402 .into_iter()
403 .find(|m| m.module_name == module)
404 .ok_or_else(|| {
405 ApiErrorResponse::with_context(
406 AppError::new(ErrorCode::NotFound, format!("unknown module: {module}")),
407 ctx,
408 )
409 })
410}
411
412fn find_loaded_module(module: &str, ctx: &RequestContext) -> Result<AdminModule, ApiErrorResponse> {
413 let admin_module = find_module(module, ctx)?;
414 if admin_module.data_source.is_some() {
415 Ok(admin_module)
416 } else {
417 Err(ApiErrorResponse::with_context(
418 AppError::new(
419 ErrorCode::ExternalDependency,
420 format!("module {module} is not loaded"),
421 )
422 .retryable(),
423 ctx,
424 ))
425 }
426}
427
428fn find_loaded_action_module(
429 module: &str,
430 ctx: &RequestContext,
431) -> Result<AdminModule, ApiErrorResponse> {
432 let admin_module = find_module(module, ctx)?;
433 if matches!(admin_module.load_status, ModuleLoadStatus::Loaded) {
434 Ok(admin_module)
435 } else {
436 Err(ApiErrorResponse::with_context(
437 AppError::new(
438 ErrorCode::ExternalDependency,
439 format!("module {module} is not loaded"),
440 )
441 .retryable(),
442 ctx,
443 ))
444 }
445}
446
447fn find_loaded_query_module(
448 module: &str,
449 ctx: &RequestContext,
450) -> Result<AdminModule, ApiErrorResponse> {
451 let admin_module = find_module(module, ctx)?;
452 if matches!(admin_module.load_status, ModuleLoadStatus::Loaded) {
453 Ok(admin_module)
454 } else {
455 Err(ApiErrorResponse::with_context(
456 AppError::new(
457 ErrorCode::ExternalDependency,
458 format!("module {module} is not loaded"),
459 )
460 .retryable(),
461 ctx,
462 ))
463 }
464}
465
466pub fn router() -> ApiOpenApiRouter {
468 OpenApiRouter::new()
469 .routes(routes!(list_modules))
470 .routes(routes!(refresh_modules))
471 .routes(routes!(available_modules))
472 .routes(routes!(install_available_module))
473 .routes(routes!(uninstall_available_module))
474 .routes(routes!(module_registry_snapshot))
475 .routes(routes!(list_schemas))
476 .routes(routes!(refresh_schemas))
477 .routes(routes!(invoke_action))
478 .routes(routes!(query_value))
479 .routes(routes!(list_records))
480 .routes(routes!(get_record))
481}