Skip to main content

pylon_plugin/
lib.rs

1use pylon_auth::AuthContext;
2use serde_json::Value;
3use std::collections::HashMap;
4use std::sync::{Arc, Mutex};
5
6// ---------------------------------------------------------------------------
7// Plugin trait — the core contract
8// ---------------------------------------------------------------------------
9
10/// A plugin extends pylon with custom routes, lifecycle hooks, and entities.
11pub trait Plugin: Send + Sync {
12    /// Unique name for this plugin.
13    fn name(&self) -> &str;
14
15    /// Called once when the plugin is registered.
16    fn on_init(&self, _ctx: &PluginContext) {}
17
18    /// Custom API routes this plugin handles.
19    fn routes(&self) -> Vec<PluginRoute> {
20        vec![]
21    }
22
23    /// Called before an entity insert. Return Err to reject.
24    fn before_insert(
25        &self,
26        _entity: &str,
27        _data: &mut Value,
28        _auth: &AuthContext,
29    ) -> Result<(), PluginError> {
30        Ok(())
31    }
32
33    /// Called after a successful insert.
34    fn after_insert(&self, _entity: &str, _id: &str, _data: &Value, _auth: &AuthContext) {}
35
36    /// Called before an entity update. Return Err to reject.
37    fn before_update(
38        &self,
39        _entity: &str,
40        _id: &str,
41        _data: &mut Value,
42        _auth: &AuthContext,
43    ) -> Result<(), PluginError> {
44        Ok(())
45    }
46
47    /// Called after a successful update.
48    fn after_update(&self, _entity: &str, _id: &str, _data: &Value, _auth: &AuthContext) {}
49
50    /// Called before an entity delete. Return Err to reject.
51    fn before_delete(
52        &self,
53        _entity: &str,
54        _id: &str,
55        _auth: &AuthContext,
56    ) -> Result<(), PluginError> {
57        Ok(())
58    }
59
60    /// Called after a successful delete.
61    fn after_delete(&self, _entity: &str, _id: &str, _auth: &AuthContext) {}
62
63    /// Called on every incoming request (middleware).
64    fn on_request(
65        &self,
66        _method: &str,
67        _path: &str,
68        _auth: &AuthContext,
69    ) -> Result<(), PluginError> {
70        Ok(())
71    }
72
73    /// Richer variant of [`on_request`] that also receives per-request
74    /// metadata (peer IP today; more fields may be added later). The
75    /// default implementation delegates to `on_request` so existing
76    /// plugins keep working without changes. Plugins that care about
77    /// IP — notably rate limiting — override this hook.
78    fn on_request_with_meta(
79        &self,
80        method: &str,
81        path: &str,
82        auth: &AuthContext,
83        _meta: &RequestMeta<'_>,
84    ) -> Result<(), PluginError> {
85        self.on_request(method, path, auth)
86    }
87
88    /// Called when a new session is created.
89    fn on_session_create(&self, _user_id: &str, _token: &str) {}
90
91    /// Additional manifest entities this plugin contributes.
92    fn entities(&self) -> Vec<pylon_kernel::ManifestEntity> {
93        vec![]
94    }
95}
96
97// ---------------------------------------------------------------------------
98// Plugin types
99// ---------------------------------------------------------------------------
100
101/// Extra per-request metadata passed to [`Plugin::on_request_with_meta`].
102///
103/// Borrowed so the server layer can construct it cheaply per request
104/// without copying. New fields may be added over time; plugins that only
105/// care about a subset should destructure by name, not by position.
106#[derive(Debug, Clone)]
107pub struct RequestMeta<'a> {
108    /// Peer IP as a string (may be empty if not derivable from the
109    /// transport, e.g. unix sockets). Routing middleware uses this to
110    /// rate-limit anonymous traffic per-IP rather than collapsing every
111    /// anon caller into one global bucket.
112    pub peer_ip: &'a str,
113}
114
115#[derive(Debug, Clone)]
116pub struct PluginError {
117    pub code: String,
118    pub message: String,
119    pub status: u16,
120}
121
122impl std::fmt::Display for PluginError {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        write!(f, "[{}] {}", self.code, self.message)
125    }
126}
127
128/// A route handler function type.
129pub type RouteHandler = Box<dyn Fn(&str, &str, &AuthContext) -> (u16, String) + Send + Sync>;
130
131/// A custom route registered by a plugin.
132pub struct PluginRoute {
133    pub method: String,
134    pub path: String,
135    pub handler: RouteHandler,
136}
137
138/// Context passed to plugins on init.
139pub struct PluginContext {
140    pub manifest: pylon_kernel::AppManifest,
141    pub data: Mutex<HashMap<String, Value>>,
142}
143
144impl PluginContext {
145    pub fn new(manifest: pylon_kernel::AppManifest) -> Self {
146        Self {
147            manifest,
148            data: Mutex::new(HashMap::new()),
149        }
150    }
151
152    /// Store plugin-specific data.
153    pub fn set(&self, key: &str, value: Value) {
154        self.data.lock().unwrap().insert(key.to_string(), value);
155    }
156
157    /// Retrieve plugin-specific data.
158    pub fn get(&self, key: &str) -> Option<Value> {
159        self.data.lock().unwrap().get(key).cloned()
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Plugin registry — manages all registered plugins
165// ---------------------------------------------------------------------------
166
167pub struct PluginRegistry {
168    plugins: Vec<Arc<dyn Plugin>>,
169    context: Arc<PluginContext>,
170}
171
172impl PluginRegistry {
173    pub fn new(manifest: pylon_kernel::AppManifest) -> Self {
174        Self {
175            plugins: Vec::new(),
176            context: Arc::new(PluginContext::new(manifest)),
177        }
178    }
179
180    /// Register a plugin.
181    pub fn register(&mut self, plugin: Arc<dyn Plugin>) {
182        plugin.on_init(&self.context);
183        self.plugins.push(plugin);
184    }
185
186    /// Get all registered plugins.
187    pub fn plugins(&self) -> &[Arc<dyn Plugin>] {
188        &self.plugins
189    }
190
191    /// Collect all custom routes from all plugins.
192    pub fn all_routes(&self) -> Vec<&PluginRoute> {
193        // Can't return references to temporary Vecs, so we need a different approach.
194        // For now, routes are checked per-plugin in the request handler.
195        vec![]
196    }
197
198    /// Run before_insert hooks. Returns first error, or Ok.
199    pub fn run_before_insert(
200        &self,
201        entity: &str,
202        data: &mut Value,
203        auth: &AuthContext,
204    ) -> Result<(), PluginError> {
205        for plugin in &self.plugins {
206            plugin.before_insert(entity, data, auth)?;
207        }
208        Ok(())
209    }
210
211    /// Run after_insert hooks.
212    pub fn run_after_insert(&self, entity: &str, id: &str, data: &Value, auth: &AuthContext) {
213        for plugin in &self.plugins {
214            plugin.after_insert(entity, id, data, auth);
215        }
216    }
217
218    /// Run before_update hooks.
219    pub fn run_before_update(
220        &self,
221        entity: &str,
222        id: &str,
223        data: &mut Value,
224        auth: &AuthContext,
225    ) -> Result<(), PluginError> {
226        for plugin in &self.plugins {
227            plugin.before_update(entity, id, data, auth)?;
228        }
229        Ok(())
230    }
231
232    /// Run after_update hooks.
233    pub fn run_after_update(&self, entity: &str, id: &str, data: &Value, auth: &AuthContext) {
234        for plugin in &self.plugins {
235            plugin.after_update(entity, id, data, auth);
236        }
237    }
238
239    /// Run before_delete hooks.
240    pub fn run_before_delete(
241        &self,
242        entity: &str,
243        id: &str,
244        auth: &AuthContext,
245    ) -> Result<(), PluginError> {
246        for plugin in &self.plugins {
247            plugin.before_delete(entity, id, auth)?;
248        }
249        Ok(())
250    }
251
252    /// Run after_delete hooks.
253    pub fn run_after_delete(&self, entity: &str, id: &str, auth: &AuthContext) {
254        for plugin in &self.plugins {
255            plugin.after_delete(entity, id, auth);
256        }
257    }
258
259    /// Run on_request middleware. Returns first error, or Ok.
260    ///
261    /// This is the legacy entry point — it has no peer-IP info, so the
262    /// built-in rate limiter degrades to a single `__anon__` bucket for
263    /// unauthenticated callers. Prefer [`run_on_request_with_meta`] from
264    /// the HTTP layer where peer IP is available.
265    pub fn run_on_request(
266        &self,
267        method: &str,
268        path: &str,
269        auth: &AuthContext,
270    ) -> Result<(), PluginError> {
271        for plugin in &self.plugins {
272            plugin.on_request(method, path, auth)?;
273        }
274        Ok(())
275    }
276
277    /// Run on_request middleware with per-request metadata. Plugins that
278    /// override `on_request_with_meta` (e.g. `RateLimitPlugin` for per-IP
279    /// bucketing) get the richer path; others fall through to the default
280    /// delegate so existing plugins keep working.
281    pub fn run_on_request_with_meta(
282        &self,
283        method: &str,
284        path: &str,
285        auth: &AuthContext,
286        meta: &RequestMeta<'_>,
287    ) -> Result<(), PluginError> {
288        for plugin in &self.plugins {
289            plugin.on_request_with_meta(method, path, auth, meta)?;
290        }
291        Ok(())
292    }
293
294    /// Try to handle a request with plugin routes.
295    pub fn try_handle_route(
296        &self,
297        method: &str,
298        path: &str,
299        body: &str,
300        auth: &AuthContext,
301    ) -> Option<(u16, String)> {
302        for plugin in &self.plugins {
303            for route in plugin.routes() {
304                if route.method == method && path.starts_with(&route.path) {
305                    return Some((route.handler)(body, path, auth));
306                }
307            }
308        }
309        None
310    }
311}
312
313// ---------------------------------------------------------------------------
314// Built-in plugins
315// ---------------------------------------------------------------------------
316
317pub mod builtin;
318
319// ---------------------------------------------------------------------------
320// Plugin marketplace — discovery and metadata registry
321// ---------------------------------------------------------------------------
322
323pub mod registry;
324
325// ---------------------------------------------------------------------------
326// Tests
327// ---------------------------------------------------------------------------
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    struct TestPlugin {
334        insert_count: Mutex<u32>,
335    }
336
337    impl TestPlugin {
338        fn new() -> Self {
339            Self {
340                insert_count: Mutex::new(0),
341            }
342        }
343        fn count(&self) -> u32 {
344            *self.insert_count.lock().unwrap()
345        }
346    }
347
348    impl Plugin for TestPlugin {
349        fn name(&self) -> &str {
350            "test"
351        }
352
353        fn after_insert(&self, _entity: &str, _id: &str, _data: &Value, _auth: &AuthContext) {
354            *self.insert_count.lock().unwrap() += 1;
355        }
356
357        fn before_insert(
358            &self,
359            entity: &str,
360            _data: &mut Value,
361            _auth: &AuthContext,
362        ) -> Result<(), PluginError> {
363            if entity == "Blocked" {
364                return Err(PluginError {
365                    code: "BLOCKED".into(),
366                    message: "Inserts to Blocked are not allowed".into(),
367                    status: 403,
368                });
369            }
370            Ok(())
371        }
372    }
373
374    fn test_manifest() -> pylon_kernel::AppManifest {
375        pylon_kernel::AppManifest {
376            manifest_version: pylon_kernel::MANIFEST_VERSION,
377            name: "test".into(),
378            version: "0.1.0".into(),
379            entities: vec![],
380            routes: vec![],
381            queries: vec![],
382            actions: vec![],
383            policies: vec![],
384            auth: Default::default(),
385        }
386    }
387
388    #[test]
389    fn register_plugin() {
390        let mut registry = PluginRegistry::new(test_manifest());
391        let plugin = Arc::new(TestPlugin::new());
392        registry.register(plugin.clone());
393        assert_eq!(registry.plugins().len(), 1);
394        assert_eq!(registry.plugins()[0].name(), "test");
395    }
396
397    #[test]
398    fn before_insert_hook_allows() {
399        let mut registry = PluginRegistry::new(test_manifest());
400        registry.register(Arc::new(TestPlugin::new()));
401
402        let mut data = serde_json::json!({"title": "test"});
403        let auth = AuthContext::anonymous();
404        let result = registry.run_before_insert("Todo", &mut data, &auth);
405        assert!(result.is_ok());
406    }
407
408    #[test]
409    fn before_insert_hook_rejects() {
410        let mut registry = PluginRegistry::new(test_manifest());
411        registry.register(Arc::new(TestPlugin::new()));
412
413        let mut data = serde_json::json!({"title": "test"});
414        let auth = AuthContext::anonymous();
415        let result = registry.run_before_insert("Blocked", &mut data, &auth);
416        assert!(result.is_err());
417        assert_eq!(result.unwrap_err().code, "BLOCKED");
418    }
419
420    #[test]
421    fn after_insert_hook_fires() {
422        let mut registry = PluginRegistry::new(test_manifest());
423        let plugin = Arc::new(TestPlugin::new());
424        registry.register(plugin.clone());
425
426        let data = serde_json::json!({"title": "test"});
427        let auth = AuthContext::anonymous();
428        registry.run_after_insert("Todo", "1", &data, &auth);
429        assert_eq!(plugin.count(), 1);
430
431        registry.run_after_insert("Todo", "2", &data, &auth);
432        assert_eq!(plugin.count(), 2);
433    }
434
435    #[test]
436    fn on_request_middleware() {
437        struct BlockAdmin;
438        impl Plugin for BlockAdmin {
439            fn name(&self) -> &str {
440                "block-admin"
441            }
442            fn on_request(
443                &self,
444                _method: &str,
445                path: &str,
446                _auth: &AuthContext,
447            ) -> Result<(), PluginError> {
448                if path.starts_with("/api/admin") {
449                    Err(PluginError {
450                        code: "FORBIDDEN".into(),
451                        message: "Admin access denied".into(),
452                        status: 403,
453                    })
454                } else {
455                    Ok(())
456                }
457            }
458        }
459
460        let mut registry = PluginRegistry::new(test_manifest());
461        registry.register(Arc::new(BlockAdmin));
462
463        let auth = AuthContext::anonymous();
464        assert!(registry
465            .run_on_request("GET", "/api/entities/Todo", &auth)
466            .is_ok());
467        assert!(registry
468            .run_on_request("GET", "/api/admin/users", &auth)
469            .is_err());
470    }
471
472    #[test]
473    fn plugin_context_data() {
474        let ctx = PluginContext::new(test_manifest());
475        ctx.set("key", serde_json::json!("value"));
476        assert_eq!(ctx.get("key"), Some(serde_json::json!("value")));
477        assert_eq!(ctx.get("missing"), None);
478    }
479
480    #[test]
481    fn plugin_error_display() {
482        let err = PluginError {
483            code: "TEST".into(),
484            message: "msg".into(),
485            status: 400,
486        };
487        assert_eq!(format!("{err}"), "[TEST] msg");
488    }
489}