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