axum_router_plugin/
lib.rs

1//! # Plugin-Based Axum Server
2//!
3//! This module provides functionality for dynamically loading and handling plugins in an Axum-based server.
4//! It allows plugins to define routes and functionality without the need to recompile the application
5//! whenever a plugin is activated or deactivated. Plugin management is handled through a configuration file,
6//! providing flexibility for dynamic behavior.
7//!
8//! The system is designed to facilitate extensibility by loading plugins that implement specific
9//! HTTP routes and handling request and response transformations, such as header manipulation and
10//! JSON response parsing.
11//!
12//! ## Key Features:
13//! - Load shared libraries as plugins.
14//! - Routes and functions from plugins are integrated into the Axum router.
15//! - Plugins can be enabled or disabled via a configuration file (`plugin.json`).
16//! - No need to recompile the main application to activate or deactivate a plugin.
17//! - **Note:** After adding, enabling, or disabling one or more plugins, it is necessary to restart the server
18//!   for the changes to take effect.
19//!
20//! ## Plugin Configuration:
21//! Each plugin inside the `plugins` directory must include a `plugins.json` file. This file specifies the library path, version, and whether the plugin is enabled.
22//! 
23//! Example `plugin.json` entry:
24//! ```json
25//! {
26//!   "name": "plugin_name",
27//!   "description": "Axum Router Plugin Example",
28//!   "lib_path": "./path/to/plugin.so",
29//!   "version": "0.1.0",
30//!   "license": "MIT",
31//!   "enabled": true
32//! }
33//! ```
34//!
35//! ## Example Usage
36//! ```rust,no_run
37//! use axum_router_plugin::Plugins;
38//! use axum::{
39//!     routing::get,
40//!     Router,
41//! };
42//!
43//! #[tokio::main]
44//! async fn main() {
45//!     // Load plugins from the plugins directory.
46//!     // Each plugin must have its own directory containing a plugin.json file
47//!     // that provides information about the plugin, such as the library path,
48//!     // version, and whether it's enabled.
49//!     //
50//!     // You can change the location of the plugins directory by setting
51//!     // the environment variable PLUGINS_DIR, for example:
52//!     // export PLUGINS_DIR=path/to/plugins
53//!     //
54//!     // Set the argument to true if you want to add the plugin name to the routes.
55//!     let axum_plugins = Plugins::new(Some(true));
56//!
57//!     // Load the plugins and create a router with the loaded plugins.
58//!     // If loading fails, the program will panic with an error message.
59//!     let plugins_router = match axum_plugins.load() {
60//!         Ok(router) => router,
61//!         Err(err) => panic!("Error loading plugins: {}", err),
62//!     };
63//!
64//!     // Build our application with a route.
65//!     // The plugins are nested under the "/plugin" path.
66//!     let _app = Router::new()
67//!         .route("/", get(|| async {
68//!             "Hello world!"
69//!         }))
70//!         .nest("/plugin", plugins_router);
71//! }
72//! ```
73//!
74//! This example demonstrates how to load plugins dynamically at runtime, configure routes, and nest plugin routes under a specified path.
75use serde::Deserialize;
76use serde_json::Value;
77use std::collections::HashMap;
78use axum::{
79    extract::RawQuery,
80    response::{Html, Json, IntoResponse},
81    routing::{get, post},
82    Router,
83};
84use hyper::{HeaderMap, header::HeaderValue};
85use libloading::{Library, Symbol};
86use std::ffi::{c_char, CStr, CString};
87use once_cell::sync::Lazy;
88use std::sync::Mutex;
89
90/// Describes a plugin route configuration, which includes:
91/// - `path`: The URL path to handle.
92/// - `function`: The name of the function in the plugin.
93/// - `method_router`: The HTTP method (GET, POST) for this route.
94/// - `response_type`: Specifies the response format (e.g., `text`, `html`, `json`).
95#[derive(Debug, Deserialize)]
96struct PluginRoute {
97    path: String,
98    function: String,
99    method_router: String,
100    response_type: String,
101}
102
103/// Defines a plugin, with metadata such as:
104/// - `name`: The plugin name.
105/// - `version`: The plugin version.
106/// - `path`: The file system path to the shared library.
107/// - `enabled`: Indicates whether the plugin is enabled.
108#[derive(Debug, Clone, Deserialize)]
109struct Plugin {
110    name: String,
111    // description: Option<String>,
112    version: String,
113    // license: Option<String>,
114    lib_path: String,
115    enabled: bool,
116}
117
118/// Struct for managing plugin loading, routing, and naming behavior.
119#[derive(Deserialize, Debug)]
120pub struct Plugins {
121    name_to_route: bool,
122}
123
124/// A global flag to enable or disable debug output, based on the `DEBUG` environment variable.
125static DEBUG: Lazy<bool> = Lazy::new(|| {
126    std::env::var("DEBUG")
127        .map(|val| val == "true")
128        .unwrap_or(false)
129});
130
131/// A global map that stores loaded plugin libraries, with the library protected by a `Mutex` to
132/// allow safe concurrent access.
133static LIBRARIES: Lazy<HashMap<String, Mutex<Library>>> = Lazy::new(|| {
134
135    let plugins_dir = std::env::var("PLUGINS_DIR")
136        .map(|val| val.is_empty()
137            .then_some("plugins".to_string()
138        )
139        .or(Some(val)).unwrap())
140        .unwrap_or("plugins".to_string());
141
142    let plugins_path = std::path::Path::new(&plugins_dir);
143    if !plugins_path.is_dir() {
144        eprintln!("Error: PLUGINS_DIR does not exist: {}", plugins_dir);
145        std::process::exit(1);
146    }
147
148    println!("Load plugins from: {}", plugins_dir);
149
150    let mut libraries = HashMap::new();
151
152    for entry in std::fs::read_dir(plugins_path).unwrap() {
153        let entry = match entry {
154            Ok(entry) => entry,
155            Err(e) => {
156                eprintln!("Error reading plugin directory entry: {}", e);
157                continue;
158            }
159        };
160
161        let path_dir = entry.path();
162        if path_dir.is_dir() {
163            println!("DIR: {}", path_dir.display());
164            let plugin_conf_path = path_dir.join("plugin.json");
165            if!plugin_conf_path.is_file() {
166                eprintln!("Error: Missing plugin.json in: {}", path_dir.display());
167                continue;
168            }
169
170            let file = std::fs::File::open(plugin_conf_path).unwrap();
171            let reader = std::io::BufReader::new(file);
172        
173            // Deserialize the JSON data into the struct
174            let plugin_conf: Plugin = match serde_json::from_reader(reader) {
175                Ok(config) => config,
176                Err(e) => {
177                    eprintln!("Error parsing plugin.json: {}", e);
178                    continue;
179                }
180            };
181
182            // Skip disabled plugins
183            if !plugin_conf.enabled {
184                eprintln!(
185                    "Skipping plugin: {}: {} - disabled", 
186                    plugin_conf.name, path_dir.display()
187                );
188                continue;
189            }
190
191            if plugin_conf.lib_path.is_empty() {
192                eprintln!(
193                    "Skipping plugin: {}: {} - no shared library path specified", 
194                    plugin_conf.name, path_dir.display()
195                );
196                continue;
197            }
198
199            let lib_path = plugin_conf.lib_path.starts_with('/')
200                .then_some(std::path::PathBuf::new().join(&plugin_conf.lib_path))
201                .or(Some(path_dir.join(&plugin_conf.lib_path))).unwrap();
202
203            if !lib_path.is_file() {
204                eprintln!(
205                    "Skipping plugin: {}: {} - shared library not found", 
206                    plugin_conf.name, path_dir.display()
207                );
208                continue;
209            }
210
211            let lib = unsafe {
212                match Library::new(&lib_path) {
213                    Ok(lib) => lib,
214                    Err(e) => panic!("Error loading library {}: {}", lib_path.display(), e),
215                }
216            };
217    
218            println!("Plugin loaded: {} Version: {}", plugin_conf.name, plugin_conf.version);
219    
220            libraries.insert(plugin_conf.name, Mutex::new(lib));
221        }
222    }
223
224    libraries
225});
226
227impl Plugins {
228
229    /// Creates a new instance of the `Plugins` struct.
230    ///
231    /// # Arguments
232    /// * `name_to_route` - An optional boolean indicating whether to prepend the plugin name to each route.
233    ///
234    /// # Returns
235    /// A new `Plugins` instance.
236    pub fn new(
237        name_to_route: Option<bool>,
238    ) -> Self {
239
240        Plugins {
241            name_to_route: match name_to_route {
242                Some(true) => true,
243                Some(false) => false,
244                None => false,
245            },
246        }
247    }
248
249    /// Handles the execution of a plugin's function, passing headers and body as arguments.
250    /// The function is executed in a blocking task, and memory is managed for the returned C string.
251    ///
252    /// # Arguments
253    /// * `headers` - The request headers.
254    /// * `body` - The request body as a string.
255    /// * `function` - A pointer to the plugin's function to execute.
256    /// * `free` - A pointer to the plugin's memory-freeing function.
257    ///
258    /// # Returns
259    /// The response as a string.
260    async fn handle_route(
261        headers: HeaderMap,
262        body: String,
263        function: extern "C" fn(*mut HeaderMap, *const c_char) -> *const c_char,
264        free: extern "C" fn(*mut c_char),
265    ) -> String {
266
267        if *DEBUG { println!("Handle Route Header Map: {:?}", headers); }
268
269        tokio::task::spawn_blocking(move || -> String {
270            // Box the headers and convert the body to a CString
271            let box_headers = Box::new(headers);
272            let c_body = CString::new(body).unwrap();
273    
274            // Call the external C function with the appropriate pointers
275            let ptr = function(Box::into_raw(box_headers), c_body.as_ptr());
276            if ptr.is_null() {
277                panic!("Received null pointer from function");
278            }
279
280            // clean this from memory
281            unsafe {
282                let data = CStr::from_ptr(ptr).to_string_lossy().into_owned();
283                free(ptr as *mut c_char);
284                data
285            }
286        }).await.unwrap()
287    }
288
289    /// Sets the appropriate response type (text, HTML, JSON) based on the `response_type` argument.
290    ///
291    /// # Arguments
292    /// * `response` - The raw response string.
293    /// * `response_type` - The expected format of the response.
294    ///
295    /// # Returns
296    /// An Axum response.
297    fn set_response(
298        response: &str,
299        response_type: &str,
300    ) -> axum::response::Response {
301
302        match response_type.to_lowercase().as_str() {
303            "text" => response.to_string()
304                .into_response(),
305            "html" => Html(response.to_string())
306                .into_response(),
307            "json" => {
308                // println!("Json String Response : {}", response.to_string());
309                let v: Value = match serde_json::from_str(response) {
310                    Ok(json_value) => json_value,
311                    Err(e) => {
312                        eprintln!("Error parsing JSON: {}", e);
313                        serde_json::Value::String(format!("Error parsing JSON: {}", e))
314                    },
315                };
316                Json(v).into_response()
317            },
318            _ => panic!("Unsupported response format"),
319        }
320    }
321
322    /// Loads and merges routes from all enabled plugins into an Axum `Router`.
323    ///
324    /// # Returns
325    /// A result containing the constructed router or an error if a plugin fails to load.
326    pub fn load(&self) -> Result<Router, libloading::Error> {
327
328        let message = || -> String {
329            let count = LIBRARIES.len();
330            format!("Loaded plugins: {}", count)
331        }();
332
333        let mut router: Router = Router::new()
334            .route("/", get(|| async {
335                message
336            })
337        );
338        
339        if LIBRARIES.is_empty() {
340            return Ok(router);
341        }
342
343        for (name, lib) in LIBRARIES.iter() {
344
345            let lib = match lib.lock() {
346                Ok(lib) => lib,
347                Err(e) => panic!("Error locking library: {}", e),
348            };
349
350            let routes_fn: Symbol<extern "C" fn() -> *const c_char> = unsafe {
351                match lib.get(b"routes\0") {
352                    Ok(symbol) => symbol,
353                    Err(e) =>  panic!("Error getting routes: {}", e),
354                }
355            };
356
357            let route_list_ptr = routes_fn();
358
359            if route_list_ptr.is_null() {
360                panic!("Received null pointer from routes function");
361            }
362
363            // clean this from memory
364            let json_data = unsafe {
365                CStr::from_ptr(route_list_ptr).to_string_lossy().into_owned()
366            };
367
368            // Clean up memory allocated by plugin if necessary
369            let free_fn: Symbol<extern "C" fn(*mut c_char)> = unsafe {
370                match lib.get(b"free\0") {
371                    Ok(symbol) => symbol,
372                    Err(e) => panic!("Error getting free function: {}", e),
373                }
374            };
375        
376            // Free the memory
377            free_fn(route_list_ptr as *mut c_char);
378
379            if *DEBUG { println!("Routes Json: {}", json_data); }
380
381            let route_list: Vec<PluginRoute> = serde_json::from_str(&json_data).unwrap();
382
383            for route in route_list {
384                // Load the plugin_route_function
385
386                let function: Symbol<extern "C" fn(*mut HeaderMap, *const c_char) -> *const c_char> = unsafe {
387                    match lib.get(route.function.as_bytes()) {
388                        Ok(symbol) => symbol,
389                        Err(e) => panic!("Error getting plugin_route_function: {}", e),
390                    }
391                };
392
393                // Move the loaded function into the closure to avoid borrowing `lib`
394                let cloned_fn = *function;
395                let cloned_free_fn = *free_fn;
396
397                // check if route.path start with "/"
398                let route_path = if self.name_to_route {
399                    format!("/{}/{}", &name, if route.path.starts_with("/") {
400                        &route.path[1..]
401                    } else {
402                        &route.path
403                    })
404                } else {
405                    route.path
406                };
407
408                // https://docs.rs/axum/latest/axum/extract/index.html
409                let r = Router::new()
410                    .route(&route_path, match route.method_router.to_lowercase().as_str() {
411                        "get" => get(move |
412                            RawQuery(query): RawQuery,
413                            mut headers: HeaderMap,
414                            body: String,
415                        | async move {
416                            if let Some(query) = query {
417                                headers.insert("x-raw-query", HeaderValue::from_str(&query).unwrap());
418                            }
419                            let response = Self::handle_route(
420                                headers,
421                                body, 
422                                cloned_fn, 
423                                cloned_free_fn,
424                            ).await;
425                            Self::set_response(&response, &route.response_type)
426                        }),
427                        "post" => post(move |
428                            RawQuery(query): RawQuery,
429                            mut headers: HeaderMap,
430                            body: String,
431                        | async move {
432                            if let Some(query) = query {
433                                headers.insert("x-raw-query", HeaderValue::from_str(&query).unwrap());
434                            }
435                            let response = Self::handle_route(
436                                headers,
437                                body, 
438                                cloned_fn, 
439                                cloned_free_fn,
440                            ).await;
441                            Self::set_response(&response, &route.response_type)
442                        }),
443                        _ => panic!("Unsupported method: {:?}", route.method_router),
444                    }
445                );
446                router = router.merge(r);
447            }
448        }
449
450        Ok(router)
451    }
452}