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}