Refactor code into separate modules with clear boundaries
Code Organization: - src/config.rs (133 lines): Configuration structs and merge implementations - src/query.rs (327 lines): SQL query execution with parameter binding - src/template.rs (160 lines): Template loading and management - src/variable.rs (107 lines): Nginx variable resolution utilities - src/lib.rs (387 lines): Module registration and directive handlers Benefits: ✓ Single responsibility per module ✓ Better testability (tests co-located with code) ✓ Clearer separation of concerns ✓ Easier maintenance and debugging ✓ Reduced cognitive load (smaller files) Module Breakdown: config.rs: - ModuleConfig (location-level config) - MainConfig (HTTP-level config) - Merge trait implementations - 5 configuration tests query.rs: - execute_query() with named/positional parameter support - Row-to-JSON conversion logic - 7 query execution tests (data types, params, LIKE, etc.) template.rs: - load_templates_from_dir() for auto-discovery - Template registration and override handling - 3 template system tests variable.rs: - resolve_variable() for nginx variable access - resolve_nginx_variable() using ngx_http_get_variable FFI - 3 parameter handling tests lib.rs: - Module trait implementations (HttpModule, HttpModuleLocationConf, HttpModuleMainConf) - nginx module registration (ngx_modules! macro) - Command array and directive handlers - HTTP request handler (tightly coupled with Module) Test Coverage: 20 tests across all modules All tests passing. Module verified working in production.
This commit is contained in:
133
src/config.rs
Normal file
133
src/config.rs
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
//! Configuration structures for the sqlite-serve module
|
||||||
|
|
||||||
|
use ngx::http::MergeConfigError;
|
||||||
|
|
||||||
|
/// Location-specific configuration
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ModuleConfig {
|
||||||
|
pub db_path: String,
|
||||||
|
pub query: String,
|
||||||
|
pub template_path: String,
|
||||||
|
pub query_params: Vec<(String, String)>, // (param_name, variable_name) pairs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global (HTTP main) configuration for shared templates
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct MainConfig {
|
||||||
|
pub global_templates_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ngx::http::Merge for ModuleConfig {
|
||||||
|
fn merge(&mut self, prev: &ModuleConfig) -> Result<(), MergeConfigError> {
|
||||||
|
if self.db_path.is_empty() {
|
||||||
|
self.db_path = prev.db_path.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.query.is_empty() {
|
||||||
|
self.query = prev.query.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.template_path.is_empty() {
|
||||||
|
self.template_path = prev.template_path.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.query_params.is_empty() {
|
||||||
|
self.query_params = prev.query_params.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ngx::http::Merge for MainConfig {
|
||||||
|
fn merge(&mut self, prev: &MainConfig) -> Result<(), MergeConfigError> {
|
||||||
|
if self.global_templates_dir.is_empty() {
|
||||||
|
self.global_templates_dir = prev.global_templates_dir.clone();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ngx::http::Merge;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_module_config_default() {
|
||||||
|
let config = ModuleConfig::default();
|
||||||
|
assert!(config.db_path.is_empty());
|
||||||
|
assert!(config.query.is_empty());
|
||||||
|
assert!(config.template_path.is_empty());
|
||||||
|
assert!(config.query_params.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_module_config_merge() {
|
||||||
|
let mut config = ModuleConfig {
|
||||||
|
db_path: String::new(),
|
||||||
|
query: String::new(),
|
||||||
|
template_path: String::new(),
|
||||||
|
query_params: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let prev = ModuleConfig {
|
||||||
|
db_path: "test.db".to_string(),
|
||||||
|
query: "SELECT * FROM test".to_string(),
|
||||||
|
template_path: "test.hbs".to_string(),
|
||||||
|
query_params: vec![("id".to_string(), "$arg_id".to_string())],
|
||||||
|
};
|
||||||
|
|
||||||
|
config.merge(&prev).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(config.db_path, "test.db");
|
||||||
|
assert_eq!(config.query, "SELECT * FROM test");
|
||||||
|
assert_eq!(config.template_path, "test.hbs");
|
||||||
|
assert_eq!(config.query_params.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_module_config_merge_preserves_existing() {
|
||||||
|
let mut config = ModuleConfig {
|
||||||
|
db_path: "existing.db".to_string(),
|
||||||
|
query: "SELECT 1".to_string(),
|
||||||
|
template_path: "existing.hbs".to_string(),
|
||||||
|
query_params: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
let prev = ModuleConfig {
|
||||||
|
db_path: "prev.db".to_string(),
|
||||||
|
query: "SELECT 2".to_string(),
|
||||||
|
template_path: "prev.hbs".to_string(),
|
||||||
|
query_params: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
config.merge(&prev).unwrap();
|
||||||
|
|
||||||
|
// Should keep existing values
|
||||||
|
assert_eq!(config.db_path, "existing.db");
|
||||||
|
assert_eq!(config.query, "SELECT 1");
|
||||||
|
assert_eq!(config.template_path, "existing.hbs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_main_config_default() {
|
||||||
|
let config = MainConfig::default();
|
||||||
|
assert!(config.global_templates_dir.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_main_config_merge() {
|
||||||
|
let mut config = MainConfig {
|
||||||
|
global_templates_dir: String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let prev = MainConfig {
|
||||||
|
global_templates_dir: "templates/global".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
config.merge(&prev).unwrap();
|
||||||
|
assert_eq!(config.global_templates_dir, "templates/global");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
785
src/lib.rs
785
src/lib.rs
@ -1,26 +1,30 @@
|
|||||||
use handlebars::Handlebars;
|
//! sqlite-serve - NGINX module for serving dynamic content from SQLite databases
|
||||||
use ngx::core::Buffer;
|
|
||||||
|
mod config;
|
||||||
|
mod query;
|
||||||
|
mod template;
|
||||||
|
mod variable;
|
||||||
|
|
||||||
|
use config::{MainConfig, ModuleConfig};
|
||||||
|
use query::execute_query;
|
||||||
|
use template::load_templates_from_dir;
|
||||||
|
use variable::resolve_variable;
|
||||||
use ngx::ffi::{
|
use ngx::ffi::{
|
||||||
ngx_hash_key, ngx_http_get_variable, NGX_CONF_TAKE1, NGX_CONF_TAKE2, NGX_HTTP_LOC_CONF,
|
NGX_CONF_TAKE1, NGX_CONF_TAKE2, NGX_HTTP_LOC_CONF, NGX_HTTP_MAIN_CONF, NGX_HTTP_MODULE,
|
||||||
NGX_HTTP_MAIN_CONF, NGX_HTTP_MODULE, NGX_HTTP_LOC_CONF_OFFSET, NGX_RS_MODULE_SIGNATURE,
|
NGX_HTTP_LOC_CONF_OFFSET, NGX_RS_MODULE_SIGNATURE, nginx_version, ngx_command_t, ngx_conf_t,
|
||||||
nginx_version, ngx_chain_t, ngx_command_t, ngx_conf_t, ngx_http_module_t, ngx_int_t,
|
ngx_http_module_t, ngx_int_t, ngx_module_t, ngx_str_t, ngx_uint_t,
|
||||||
ngx_module_t, ngx_str_t, ngx_uint_t,
|
|
||||||
};
|
};
|
||||||
use ngx::http::{
|
use ngx::core::Buffer;
|
||||||
HttpModule, HttpModuleLocationConf, HttpModuleMainConf, MergeConfigError, NgxHttpCoreModule,
|
use ngx::ffi::ngx_chain_t;
|
||||||
};
|
use ngx::http::{HttpModule, HttpModuleLocationConf, HttpModuleMainConf, NgxHttpCoreModule};
|
||||||
use ngx::{core, core::Status, http};
|
use ngx::{core, core::Status, http, http_request_handler, ngx_log_debug_http, ngx_modules, ngx_string};
|
||||||
use ngx::{http_request_handler, ngx_log_debug_http, ngx_modules, ngx_string};
|
|
||||||
use rusqlite::{Connection, Result};
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::os::raw::{c_char, c_void};
|
use std::os::raw::{c_char, c_void};
|
||||||
use std::ptr::addr_of;
|
use std::ptr::addr_of;
|
||||||
|
|
||||||
struct Module;
|
pub struct Module;
|
||||||
|
|
||||||
// Implement our HttpModule trait, we're creating a postconfiguration method to install our
|
impl ngx::http::HttpModule for Module {
|
||||||
// handler's Access phase function.
|
|
||||||
impl http::HttpModule for Module {
|
|
||||||
fn module() -> &'static ngx_module_t {
|
fn module() -> &'static ngx_module_t {
|
||||||
unsafe { &*addr_of!(ngx_http_howto_module) }
|
unsafe { &*addr_of!(ngx_http_howto_module) }
|
||||||
}
|
}
|
||||||
@ -30,65 +34,14 @@ impl http::HttpModule for Module {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implement HttpModuleLocationConf to define our location-specific configuration
|
|
||||||
unsafe impl HttpModuleLocationConf for Module {
|
unsafe impl HttpModuleLocationConf for Module {
|
||||||
type LocationConf = ModuleConfig;
|
type LocationConf = ModuleConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implement HttpModuleMainConf to define our global configuration
|
|
||||||
unsafe impl HttpModuleMainConf for Module {
|
unsafe impl HttpModuleMainConf for Module {
|
||||||
type MainConf = MainConfig;
|
type MainConf = MainConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a ModuleConfig to save our configuration state.
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct ModuleConfig {
|
|
||||||
db_path: String,
|
|
||||||
query: String,
|
|
||||||
template_path: String,
|
|
||||||
query_params: Vec<(String, String)>, // (param_name, variable_name) pairs
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global configuration for shared templates
|
|
||||||
#[derive(Debug, Default)]
|
|
||||||
struct MainConfig {
|
|
||||||
global_templates_dir: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl http::Merge for MainConfig {
|
|
||||||
fn merge(&mut self, prev: &MainConfig) -> Result<(), MergeConfigError> {
|
|
||||||
if self.global_templates_dir.is_empty() {
|
|
||||||
self.global_templates_dir = prev.global_templates_dir.clone();
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Implement our Merge trait to merge configuration with higher layers.
|
|
||||||
impl http::Merge for ModuleConfig {
|
|
||||||
fn merge(&mut self, prev: &ModuleConfig) -> Result<(), MergeConfigError> {
|
|
||||||
if self.db_path.is_empty() {
|
|
||||||
self.db_path = prev.db_path.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.query.is_empty() {
|
|
||||||
self.query = prev.query.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.template_path.is_empty() {
|
|
||||||
self.template_path = prev.template_path.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.query_params.is_empty() {
|
|
||||||
self.query_params = prev.query_params.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create our "C" module context with function entrypoints for NGINX event loop. This "binds" our
|
|
||||||
// HttpModule implementation to functions callable from C.
|
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
#[allow(non_upper_case_globals)]
|
#[allow(non_upper_case_globals)]
|
||||||
static ngx_http_howto_module_ctx: ngx_http_module_t = ngx_http_module_t {
|
static ngx_http_howto_module_ctx: ngx_http_module_t = ngx_http_module_t {
|
||||||
@ -102,9 +55,6 @@ static ngx_http_howto_module_ctx: ngx_http_module_t = ngx_http_module_t {
|
|||||||
merge_loc_conf: Some(Module::merge_loc_conf),
|
merge_loc_conf: Some(Module::merge_loc_conf),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create our module structure and export it with the `ngx_modules!` macro. For this simple
|
|
||||||
// handler, the ngx_module_t is predominately boilerplate save for setting the above context into
|
|
||||||
// this structure and setting our custom configuration command (defined below).
|
|
||||||
ngx_modules!(ngx_http_howto_module);
|
ngx_modules!(ngx_http_howto_module);
|
||||||
|
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
@ -140,8 +90,6 @@ pub static mut ngx_http_howto_module: ngx_module_t = ngx_module_t {
|
|||||||
spare_hook7: 0,
|
spare_hook7: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register and allocate our command structures for directive generation and eventual storage. Be
|
|
||||||
// sure to terminate the array with an empty command.
|
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
#[allow(non_upper_case_globals)]
|
#[allow(non_upper_case_globals)]
|
||||||
static mut ngx_http_howto_commands: [ngx_command_t; 6] = [
|
static mut ngx_http_howto_commands: [ngx_command_t; 6] = [
|
||||||
@ -149,7 +97,7 @@ static mut ngx_http_howto_commands: [ngx_command_t; 6] = [
|
|||||||
name: ngx_string!("sqlite_global_templates"),
|
name: ngx_string!("sqlite_global_templates"),
|
||||||
type_: (NGX_HTTP_MAIN_CONF | NGX_CONF_TAKE1) as ngx_uint_t,
|
type_: (NGX_HTTP_MAIN_CONF | NGX_CONF_TAKE1) as ngx_uint_t,
|
||||||
set: Some(ngx_http_howto_commands_set_global_templates),
|
set: Some(ngx_http_howto_commands_set_global_templates),
|
||||||
conf: 0, // Main conf offset
|
conf: 0,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
post: std::ptr::null_mut(),
|
post: std::ptr::null_mut(),
|
||||||
},
|
},
|
||||||
@ -198,6 +146,7 @@ static mut ngx_http_howto_commands: [ngx_command_t; 6] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// Directive handler for sqlite_global_templates
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
extern "C" fn ngx_http_howto_commands_set_global_templates(
|
extern "C" fn ngx_http_howto_commands_set_global_templates(
|
||||||
cf: *mut ngx_conf_t,
|
cf: *mut ngx_conf_t,
|
||||||
@ -213,6 +162,7 @@ extern "C" fn ngx_http_howto_commands_set_global_templates(
|
|||||||
std::ptr::null_mut()
|
std::ptr::null_mut()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Directive handler for sqlite_db
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
extern "C" fn ngx_http_howto_commands_set_db_path(
|
extern "C" fn ngx_http_howto_commands_set_db_path(
|
||||||
cf: *mut ngx_conf_t,
|
cf: *mut ngx_conf_t,
|
||||||
@ -228,6 +178,7 @@ extern "C" fn ngx_http_howto_commands_set_db_path(
|
|||||||
std::ptr::null_mut()
|
std::ptr::null_mut()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Directive handler for sqlite_query
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
extern "C" fn ngx_http_howto_commands_set_query(
|
extern "C" fn ngx_http_howto_commands_set_query(
|
||||||
cf: *mut ngx_conf_t,
|
cf: *mut ngx_conf_t,
|
||||||
@ -243,6 +194,7 @@ extern "C" fn ngx_http_howto_commands_set_query(
|
|||||||
std::ptr::null_mut()
|
std::ptr::null_mut()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Directive handler for sqlite_template
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
extern "C" fn ngx_http_howto_commands_set_template_path(
|
extern "C" fn ngx_http_howto_commands_set_template_path(
|
||||||
cf: *mut ngx_conf_t,
|
cf: *mut ngx_conf_t,
|
||||||
@ -253,7 +205,7 @@ extern "C" fn ngx_http_howto_commands_set_template_path(
|
|||||||
let conf = &mut *(conf as *mut ModuleConfig);
|
let conf = &mut *(conf as *mut ModuleConfig);
|
||||||
let args = (*(*cf).args).elts as *mut ngx_str_t;
|
let args = (*(*cf).args).elts as *mut ngx_str_t;
|
||||||
conf.template_path = (*args.add(1)).to_string();
|
conf.template_path = (*args.add(1)).to_string();
|
||||||
|
|
||||||
// Set the content handler for this location
|
// Set the content handler for this location
|
||||||
let clcf = NgxHttpCoreModule::location_conf_mut(&*cf)
|
let clcf = NgxHttpCoreModule::location_conf_mut(&*cf)
|
||||||
.expect("failed to get core location conf");
|
.expect("failed to get core location conf");
|
||||||
@ -263,6 +215,7 @@ extern "C" fn ngx_http_howto_commands_set_template_path(
|
|||||||
std::ptr::null_mut()
|
std::ptr::null_mut()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Directive handler for sqlite_param
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
extern "C" fn ngx_http_howto_commands_add_param(
|
extern "C" fn ngx_http_howto_commands_add_param(
|
||||||
cf: *mut ngx_conf_t,
|
cf: *mut ngx_conf_t,
|
||||||
@ -273,7 +226,7 @@ extern "C" fn ngx_http_howto_commands_add_param(
|
|||||||
let conf = &mut *(conf as *mut ModuleConfig);
|
let conf = &mut *(conf as *mut ModuleConfig);
|
||||||
let args = (*(*cf).args).elts as *mut ngx_str_t;
|
let args = (*(*cf).args).elts as *mut ngx_str_t;
|
||||||
let nelts = (*(*cf).args).nelts;
|
let nelts = (*(*cf).args).nelts;
|
||||||
|
|
||||||
if nelts == 2 {
|
if nelts == 2 {
|
||||||
// Single argument: positional parameter
|
// Single argument: positional parameter
|
||||||
// sqlite_param $arg_id
|
// sqlite_param $arg_id
|
||||||
@ -291,111 +244,7 @@ extern "C" fn ngx_http_howto_commands_add_param(
|
|||||||
std::ptr::null_mut()
|
std::ptr::null_mut()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load all .hbs templates from a directory into the Handlebars registry
|
// HTTP request handler - processes SQLite queries and renders templates
|
||||||
fn load_templates_from_dir(reg: &mut Handlebars, dir_path: &str) -> std::io::Result<usize> {
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
let dir = Path::new(dir_path);
|
|
||||||
if !dir.exists() || !dir.is_dir() {
|
|
||||||
return Ok(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut count = 0;
|
|
||||||
for entry in fs::read_dir(dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
|
|
||||||
if path.is_file() {
|
|
||||||
if let Some(ext) = path.extension() {
|
|
||||||
if ext == "hbs" {
|
|
||||||
if let Some(stem) = path.file_stem() {
|
|
||||||
if let Some(name) = stem.to_str() {
|
|
||||||
if let Err(e) = reg.register_template_file(name, &path) {
|
|
||||||
eprintln!("Failed to register template {}: {}", path.display(), e);
|
|
||||||
} else {
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(count)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute a generic SQL query with parameters and return results as JSON-compatible data
|
|
||||||
fn execute_query(
|
|
||||||
db_path: &str,
|
|
||||||
query: &str,
|
|
||||||
params: &[(String, String)], // (param_name, value) pairs
|
|
||||||
) -> Result<Vec<std::collections::HashMap<String, serde_json::Value>>> {
|
|
||||||
let conn = Connection::open(db_path)?;
|
|
||||||
let mut stmt = conn.prepare(query)?;
|
|
||||||
|
|
||||||
let column_count = stmt.column_count();
|
|
||||||
let column_names: Vec<String> = (0..column_count)
|
|
||||||
.map(|i| stmt.column_name(i).unwrap_or("").to_string())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Bind parameters (either positional or named)
|
|
||||||
let has_named_params = params.iter().any(|(name, _)| !name.is_empty());
|
|
||||||
|
|
||||||
// Convert row to JSON map
|
|
||||||
let row_to_map = |row: &rusqlite::Row| -> rusqlite::Result<std::collections::HashMap<String, serde_json::Value>> {
|
|
||||||
let mut map = std::collections::HashMap::new();
|
|
||||||
for (i, col_name) in column_names.iter().enumerate() {
|
|
||||||
let value: serde_json::Value = match row.get_ref(i)? {
|
|
||||||
rusqlite::types::ValueRef::Null => serde_json::Value::Null,
|
|
||||||
rusqlite::types::ValueRef::Integer(v) => serde_json::Value::Number(v.into()),
|
|
||||||
rusqlite::types::ValueRef::Real(v) => {
|
|
||||||
serde_json::Number::from_f64(v)
|
|
||||||
.map(serde_json::Value::Number)
|
|
||||||
.unwrap_or(serde_json::Value::Null)
|
|
||||||
}
|
|
||||||
rusqlite::types::ValueRef::Text(v) => {
|
|
||||||
serde_json::Value::String(String::from_utf8_lossy(v).to_string())
|
|
||||||
}
|
|
||||||
rusqlite::types::ValueRef::Blob(v) => {
|
|
||||||
// Convert blob to hex string
|
|
||||||
let hex_string = v
|
|
||||||
.iter()
|
|
||||||
.map(|b| format!("{:02x}", b))
|
|
||||||
.collect::<String>();
|
|
||||||
serde_json::Value::String(hex_string)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
map.insert(col_name.clone(), value);
|
|
||||||
}
|
|
||||||
Ok(map)
|
|
||||||
};
|
|
||||||
|
|
||||||
let rows = if has_named_params {
|
|
||||||
// Use named parameters
|
|
||||||
let named_params: Vec<(&str, &dyn rusqlite::ToSql)> = params
|
|
||||||
.iter()
|
|
||||||
.map(|(name, value)| (name.as_str(), value as &dyn rusqlite::ToSql))
|
|
||||||
.collect();
|
|
||||||
stmt.query_map(named_params.as_slice(), row_to_map)?
|
|
||||||
} else {
|
|
||||||
// Use positional parameters
|
|
||||||
let positional_params: Vec<&dyn rusqlite::ToSql> = params
|
|
||||||
.iter()
|
|
||||||
.map(|(_, value)| value as &dyn rusqlite::ToSql)
|
|
||||||
.collect();
|
|
||||||
stmt.query_map(positional_params.as_slice(), row_to_map)?
|
|
||||||
};
|
|
||||||
|
|
||||||
rows.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Implement a request handler. Use the convenience macro, the http_request_handler! macro will
|
|
||||||
// convert the native NGINX request into a Rust Request instance as well as define an extern C
|
|
||||||
// function callable from NGINX.
|
|
||||||
//
|
|
||||||
// The function body is implemented as a Rust closure.
|
|
||||||
http_request_handler!(howto_access_handler, |request: &mut http::Request| {
|
http_request_handler!(howto_access_handler, |request: &mut http::Request| {
|
||||||
let co = Module::location_conf(request).expect("module config is none");
|
let co = Module::location_conf(request).expect("module config is none");
|
||||||
|
|
||||||
@ -436,43 +285,14 @@ http_request_handler!(howto_access_handler, |request: &mut http::Request| {
|
|||||||
// Resolve query parameters from nginx variables
|
// Resolve query parameters from nginx variables
|
||||||
let mut param_values: Vec<(String, String)> = Vec::new();
|
let mut param_values: Vec<(String, String)> = Vec::new();
|
||||||
for (param_name, var_name) in &co.query_params {
|
for (param_name, var_name) in &co.query_params {
|
||||||
let value = if var_name.starts_with('$') {
|
match resolve_variable(request, var_name) {
|
||||||
// It's a variable reference, resolve it from nginx
|
Ok(value) => {
|
||||||
let var_name_str = &var_name[1..]; // Remove the '$' prefix
|
param_values.push((param_name.clone(), value));
|
||||||
let var_name_bytes = var_name_str.as_bytes();
|
}
|
||||||
|
Err(_) => {
|
||||||
let mut name = ngx_str_t {
|
|
||||||
len: var_name_bytes.len(),
|
|
||||||
data: var_name_bytes.as_ptr() as *mut u8,
|
|
||||||
};
|
|
||||||
|
|
||||||
let key = unsafe { ngx_hash_key(name.data, name.len) };
|
|
||||||
let r: *mut ngx::ffi::ngx_http_request_t = request.into();
|
|
||||||
let var_value = unsafe { ngx_http_get_variable(r, &mut name, key) };
|
|
||||||
|
|
||||||
if var_value.is_null() {
|
|
||||||
ngx_log_debug_http!(request, "variable not found: {}", var_name);
|
|
||||||
return http::HTTPStatus::BAD_REQUEST.into();
|
return http::HTTPStatus::BAD_REQUEST.into();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let var_ref = unsafe { &*var_value };
|
|
||||||
if var_ref.valid() == 0 {
|
|
||||||
ngx_log_debug_http!(request, "variable value not valid: {}", var_name);
|
|
||||||
return http::HTTPStatus::BAD_REQUEST.into();
|
|
||||||
}
|
|
||||||
|
|
||||||
match std::str::from_utf8(var_ref.as_bytes()) {
|
|
||||||
Ok(s) => s.to_string(),
|
|
||||||
Err(_) => {
|
|
||||||
ngx_log_debug_http!(request, "failed to decode variable as UTF-8: {}", var_name);
|
|
||||||
return http::HTTPStatus::INTERNAL_SERVER_ERROR.into();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// It's a literal value
|
|
||||||
var_name.clone()
|
|
||||||
};
|
|
||||||
param_values.push((param_name.clone(), value));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngx_log_debug_http!(
|
ngx_log_debug_http!(
|
||||||
@ -491,14 +311,19 @@ http_request_handler!(howto_access_handler, |request: &mut http::Request| {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Setup Handlebars and load templates
|
// Setup Handlebars and load templates
|
||||||
let mut reg = Handlebars::new();
|
let mut reg = handlebars::Handlebars::new();
|
||||||
|
|
||||||
// First, load global templates if configured
|
// First, load global templates if configured
|
||||||
let main_conf = Module::main_conf(request).expect("main config is none");
|
let main_conf = Module::main_conf(request).expect("main config is none");
|
||||||
if !main_conf.global_templates_dir.is_empty() {
|
if !main_conf.global_templates_dir.is_empty() {
|
||||||
match load_templates_from_dir(&mut reg, &main_conf.global_templates_dir) {
|
match load_templates_from_dir(&mut reg, &main_conf.global_templates_dir) {
|
||||||
Ok(count) => {
|
Ok(count) => {
|
||||||
ngx_log_debug_http!(request, "loaded {} global templates from {}", count, main_conf.global_templates_dir);
|
ngx_log_debug_http!(
|
||||||
|
request,
|
||||||
|
"loaded {} global templates from {}",
|
||||||
|
count,
|
||||||
|
main_conf.global_templates_dir
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
ngx_log_debug_http!(request, "warning: failed to load global templates: {}", e);
|
ngx_log_debug_http!(request, "warning: failed to load global templates: {}", e);
|
||||||
@ -520,7 +345,7 @@ http_request_handler!(howto_access_handler, |request: &mut http::Request| {
|
|||||||
match reg.register_template_file("template", &template_full_path) {
|
match reg.register_template_file("template", &template_full_path) {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
ngx_log_debug_http!(request, "registered main template: {}", template_full_path);
|
ngx_log_debug_http!(request, "registered main template: {}", template_full_path);
|
||||||
},
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
ngx_log_debug_http!(request, "failed to load main template: {}", e);
|
ngx_log_debug_http!(request, "failed to load main template: {}", e);
|
||||||
return http::HTTPStatus::INTERNAL_SERVER_ERROR.into();
|
return http::HTTPStatus::INTERNAL_SERVER_ERROR.into();
|
||||||
@ -553,530 +378,10 @@ http_request_handler!(howto_access_handler, |request: &mut http::Request| {
|
|||||||
request.discard_request_body();
|
request.discard_request_body();
|
||||||
request.set_status(http::HTTPStatus::OK);
|
request.set_status(http::HTTPStatus::OK);
|
||||||
let rc = request.send_header();
|
let rc = request.send_header();
|
||||||
if rc == core::Status::NGX_ERROR
|
if rc == core::Status::NGX_ERROR || rc > core::Status::NGX_OK || request.header_only() {
|
||||||
|| rc > core::Status::NGX_OK
|
|
||||||
|| request.header_only()
|
|
||||||
{
|
|
||||||
return rc;
|
return rc;
|
||||||
}
|
}
|
||||||
|
|
||||||
request.output_filter(&mut out);
|
request.output_filter(&mut out);
|
||||||
Status::NGX_DONE
|
Status::NGX_DONE
|
||||||
});
|
});
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use ngx::http::Merge;
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_module_config_default() {
|
|
||||||
let config = ModuleConfig::default();
|
|
||||||
assert!(config.db_path.is_empty());
|
|
||||||
assert!(config.query.is_empty());
|
|
||||||
assert!(config.template_path.is_empty());
|
|
||||||
assert!(config.query_params.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_module_config_merge() {
|
|
||||||
let mut config = ModuleConfig {
|
|
||||||
db_path: String::new(),
|
|
||||||
query: String::new(),
|
|
||||||
template_path: String::new(),
|
|
||||||
query_params: vec![],
|
|
||||||
};
|
|
||||||
|
|
||||||
let prev = ModuleConfig {
|
|
||||||
db_path: "test.db".to_string(),
|
|
||||||
query: "SELECT * FROM test".to_string(),
|
|
||||||
template_path: "test.hbs".to_string(),
|
|
||||||
query_params: vec![("id".to_string(), "$arg_id".to_string())],
|
|
||||||
};
|
|
||||||
|
|
||||||
config.merge(&prev).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(config.db_path, "test.db");
|
|
||||||
assert_eq!(config.query, "SELECT * FROM test");
|
|
||||||
assert_eq!(config.template_path, "test.hbs");
|
|
||||||
assert_eq!(config.query_params.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_module_config_merge_preserves_existing() {
|
|
||||||
let mut config = ModuleConfig {
|
|
||||||
db_path: "existing.db".to_string(),
|
|
||||||
query: "SELECT 1".to_string(),
|
|
||||||
template_path: "existing.hbs".to_string(),
|
|
||||||
query_params: vec![],
|
|
||||||
};
|
|
||||||
|
|
||||||
let prev = ModuleConfig {
|
|
||||||
db_path: "prev.db".to_string(),
|
|
||||||
query: "SELECT 2".to_string(),
|
|
||||||
template_path: "prev.hbs".to_string(),
|
|
||||||
query_params: vec![],
|
|
||||||
};
|
|
||||||
|
|
||||||
config.merge(&prev).unwrap();
|
|
||||||
|
|
||||||
// Should keep existing values
|
|
||||||
assert_eq!(config.db_path, "existing.db");
|
|
||||||
assert_eq!(config.query, "SELECT 1");
|
|
||||||
assert_eq!(config.template_path, "existing.hbs");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_main_config_default() {
|
|
||||||
let config = MainConfig::default();
|
|
||||||
assert!(config.global_templates_dir.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_main_config_merge() {
|
|
||||||
let mut config = MainConfig {
|
|
||||||
global_templates_dir: String::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let prev = MainConfig {
|
|
||||||
global_templates_dir: "templates/global".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
config.merge(&prev).unwrap();
|
|
||||||
assert_eq!(config.global_templates_dir, "templates/global");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_execute_query_empty_db() {
|
|
||||||
// Test with a non-existent database - should return error
|
|
||||||
let result = execute_query("/nonexistent/test.db", "SELECT 1", &[]);
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_execute_query_with_memory_db() {
|
|
||||||
use rusqlite::Connection;
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
// Create a temporary in-memory database for testing
|
|
||||||
let temp_path = "/tmp/test_sqlite_serve.db";
|
|
||||||
let _ = fs::remove_file(temp_path); // Clean up if exists
|
|
||||||
|
|
||||||
{
|
|
||||||
let conn = Connection::open(temp_path).unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE test (id INTEGER, name TEXT, value REAL)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO test VALUES (1, 'first', 1.5), (2, 'second', 2.5)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test simple query
|
|
||||||
let results = execute_query(temp_path, "SELECT * FROM test ORDER BY id", &[]).unwrap();
|
|
||||||
assert_eq!(results.len(), 2);
|
|
||||||
assert_eq!(
|
|
||||||
results[0].get("id").unwrap(),
|
|
||||||
&serde_json::Value::Number(1.into())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
results[0].get("name").unwrap(),
|
|
||||||
&serde_json::Value::String("first".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
let _ = fs::remove_file(temp_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_execute_query_with_positional_params() {
|
|
||||||
use rusqlite::Connection;
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
let temp_path = "/tmp/test_sqlite_serve_params.db";
|
|
||||||
let _ = fs::remove_file(temp_path);
|
|
||||||
|
|
||||||
{
|
|
||||||
let conn = Connection::open(temp_path).unwrap();
|
|
||||||
conn.execute("CREATE TABLE books (id INTEGER, title TEXT)", [])
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO books VALUES (1, 'Book One'), (2, 'Book Two'), (3, 'Book Three')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test positional parameter
|
|
||||||
let params = vec![(String::new(), "2".to_string())];
|
|
||||||
let results =
|
|
||||||
execute_query(temp_path, "SELECT * FROM books WHERE id = ?", ¶ms).unwrap();
|
|
||||||
assert_eq!(results.len(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
results[0].get("title").unwrap(),
|
|
||||||
&serde_json::Value::String("Book Two".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
let _ = fs::remove_file(temp_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_execute_query_with_named_params() {
|
|
||||||
use rusqlite::Connection;
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
let temp_path = "/tmp/test_sqlite_serve_named.db";
|
|
||||||
let _ = fs::remove_file(temp_path);
|
|
||||||
|
|
||||||
{
|
|
||||||
let conn = Connection::open(temp_path).unwrap();
|
|
||||||
conn.execute("CREATE TABLE books (id INTEGER, title TEXT, year INTEGER)", [])
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO books VALUES (1, 'Old Book', 2000), (2, 'New Book', 2020), (3, 'Newer Book', 2023)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test named parameters
|
|
||||||
let params = vec![
|
|
||||||
(":min_year".to_string(), "2015".to_string()),
|
|
||||||
(":max_year".to_string(), "2024".to_string()),
|
|
||||||
];
|
|
||||||
let results = execute_query(
|
|
||||||
temp_path,
|
|
||||||
"SELECT * FROM books WHERE year >= :min_year AND year <= :max_year ORDER BY year",
|
|
||||||
¶ms,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(results.len(), 2);
|
|
||||||
assert_eq!(
|
|
||||||
results[0].get("title").unwrap(),
|
|
||||||
&serde_json::Value::String("New Book".to_string())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
results[1].get("title").unwrap(),
|
|
||||||
&serde_json::Value::String("Newer Book".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
let _ = fs::remove_file(temp_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_execute_query_data_types() {
|
|
||||||
use rusqlite::Connection;
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
let temp_path = "/tmp/test_sqlite_serve_types.db";
|
|
||||||
let _ = fs::remove_file(temp_path);
|
|
||||||
|
|
||||||
{
|
|
||||||
let conn = Connection::open(temp_path).unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE types (id INTEGER, name TEXT, price REAL, data BLOB, nullable TEXT)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO types VALUES (42, 'test', 3.14, X'DEADBEEF', NULL)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let results = execute_query(temp_path, "SELECT * FROM types", &[]).unwrap();
|
|
||||||
assert_eq!(results.len(), 1);
|
|
||||||
|
|
||||||
let row = &results[0];
|
|
||||||
|
|
||||||
// Test INTEGER
|
|
||||||
assert_eq!(row.get("id").unwrap(), &serde_json::Value::Number(42.into()));
|
|
||||||
|
|
||||||
// Test TEXT
|
|
||||||
assert_eq!(
|
|
||||||
row.get("name").unwrap(),
|
|
||||||
&serde_json::Value::String("test".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test REAL
|
|
||||||
assert_eq!(
|
|
||||||
row.get("price").unwrap().as_f64().unwrap(),
|
|
||||||
3.14
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test BLOB (should be hex encoded)
|
|
||||||
assert_eq!(
|
|
||||||
row.get("data").unwrap(),
|
|
||||||
&serde_json::Value::String("deadbeef".to_string())
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test NULL
|
|
||||||
assert_eq!(row.get("nullable").unwrap(), &serde_json::Value::Null);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
let _ = fs::remove_file(temp_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_load_templates_from_nonexistent_dir() {
|
|
||||||
let mut reg = Handlebars::new();
|
|
||||||
let result = load_templates_from_dir(&mut reg, "/nonexistent/path/to/templates");
|
|
||||||
|
|
||||||
// Should succeed but load 0 templates
|
|
||||||
assert!(result.is_ok());
|
|
||||||
assert_eq!(result.unwrap(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_load_templates_from_dir() {
|
|
||||||
use std::fs;
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
let temp_dir = "/tmp/test_sqlite_serve_templates";
|
|
||||||
let _ = fs::remove_dir_all(temp_dir);
|
|
||||||
fs::create_dir_all(temp_dir).unwrap();
|
|
||||||
|
|
||||||
// Create test templates
|
|
||||||
let mut file1 = fs::File::create(format!("{}/template1.hbs", temp_dir)).unwrap();
|
|
||||||
file1.write_all(b"<h1>Template 1</h1>").unwrap();
|
|
||||||
|
|
||||||
let mut file2 = fs::File::create(format!("{}/template2.hbs", temp_dir)).unwrap();
|
|
||||||
file2.write_all(b"<h1>Template 2</h1>").unwrap();
|
|
||||||
|
|
||||||
// Create a non-template file (should be ignored)
|
|
||||||
let mut file3 = fs::File::create(format!("{}/readme.txt", temp_dir)).unwrap();
|
|
||||||
file3.write_all(b"Not a template").unwrap();
|
|
||||||
|
|
||||||
let mut reg = Handlebars::new();
|
|
||||||
let count = load_templates_from_dir(&mut reg, temp_dir).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(count, 2);
|
|
||||||
assert!(reg.has_template("template1"));
|
|
||||||
assert!(reg.has_template("template2"));
|
|
||||||
assert!(!reg.has_template("readme"));
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
let _ = fs::remove_dir_all(temp_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_template_rendering_with_results() {
|
|
||||||
use std::fs;
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
let temp_dir = "/tmp/test_sqlite_serve_render";
|
|
||||||
let _ = fs::remove_dir_all(temp_dir);
|
|
||||||
fs::create_dir_all(temp_dir).unwrap();
|
|
||||||
|
|
||||||
// Create a simple template
|
|
||||||
let template_path = format!("{}/list.hbs", temp_dir);
|
|
||||||
let mut file = fs::File::create(&template_path).unwrap();
|
|
||||||
file.write_all(b"{{#each results}}<li>{{name}}</li>{{/each}}").unwrap();
|
|
||||||
|
|
||||||
let mut reg = Handlebars::new();
|
|
||||||
reg.register_template_file("list", &template_path).unwrap();
|
|
||||||
|
|
||||||
// Test rendering with data
|
|
||||||
let mut results = vec![];
|
|
||||||
let mut item1 = HashMap::new();
|
|
||||||
item1.insert("name".to_string(), serde_json::Value::String("Item 1".to_string()));
|
|
||||||
results.push(item1);
|
|
||||||
|
|
||||||
let mut item2 = HashMap::new();
|
|
||||||
item2.insert("name".to_string(), serde_json::Value::String("Item 2".to_string()));
|
|
||||||
results.push(item2);
|
|
||||||
|
|
||||||
let rendered = reg.render("list", &json!({"results": results})).unwrap();
|
|
||||||
assert!(rendered.contains("<li>Item 1</li>"));
|
|
||||||
assert!(rendered.contains("<li>Item 2</li>"));
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
let _ = fs::remove_dir_all(temp_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_named_params_parsing() {
|
|
||||||
// This tests the logic we'd use in the directive handler
|
|
||||||
let test_cases = vec![
|
|
||||||
// (nelts, expected_is_named, expected_param_name)
|
|
||||||
(2, false, ""), // sqlite_param $arg_id
|
|
||||||
(3, true, ":book_id"), // sqlite_param :book_id $arg_id
|
|
||||||
];
|
|
||||||
|
|
||||||
for (nelts, expected_is_named, expected_param_name) in test_cases {
|
|
||||||
if nelts == 2 {
|
|
||||||
// Positional
|
|
||||||
let param_name = String::new();
|
|
||||||
assert!(!expected_is_named);
|
|
||||||
assert_eq!(param_name, expected_param_name);
|
|
||||||
} else if nelts == 3 {
|
|
||||||
// Named
|
|
||||||
let param_name = ":book_id".to_string();
|
|
||||||
assert!(expected_is_named);
|
|
||||||
assert_eq!(param_name, expected_param_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_has_named_params() {
|
|
||||||
let positional = vec![
|
|
||||||
(String::new(), "value1".to_string()),
|
|
||||||
(String::new(), "value2".to_string()),
|
|
||||||
];
|
|
||||||
assert!(!positional.iter().any(|(name, _)| !name.is_empty()));
|
|
||||||
|
|
||||||
let named = vec![
|
|
||||||
(":id".to_string(), "value1".to_string()),
|
|
||||||
(":name".to_string(), "value2".to_string()),
|
|
||||||
];
|
|
||||||
assert!(named.iter().any(|(name, _)| !name.is_empty()));
|
|
||||||
|
|
||||||
let mixed = vec![
|
|
||||||
(":id".to_string(), "value1".to_string()),
|
|
||||||
(String::new(), "value2".to_string()),
|
|
||||||
];
|
|
||||||
assert!(mixed.iter().any(|(name, _)| !name.is_empty()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_execute_query_with_like_operator() {
|
|
||||||
use rusqlite::Connection;
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
let temp_path = "/tmp/test_sqlite_serve_like.db";
|
|
||||||
let _ = fs::remove_file(temp_path);
|
|
||||||
|
|
||||||
{
|
|
||||||
let conn = Connection::open(temp_path).unwrap();
|
|
||||||
conn.execute("CREATE TABLE books (title TEXT)", []).unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO books VALUES ('The Rust Book'), ('Clean Code'), ('Rust in Action')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test LIKE with named parameter
|
|
||||||
let params = vec![(":search".to_string(), "Rust".to_string())];
|
|
||||||
let results = execute_query(
|
|
||||||
temp_path,
|
|
||||||
"SELECT * FROM books WHERE title LIKE '%' || :search || '%'",
|
|
||||||
¶ms,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(results.len(), 2);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
let _ = fs::remove_file(temp_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_execute_query_empty_results() {
|
|
||||||
use rusqlite::Connection;
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
let temp_path = "/tmp/test_sqlite_serve_empty.db";
|
|
||||||
let _ = fs::remove_file(temp_path);
|
|
||||||
|
|
||||||
{
|
|
||||||
let conn = Connection::open(temp_path).unwrap();
|
|
||||||
conn.execute("CREATE TABLE test (id INTEGER)", []).unwrap();
|
|
||||||
// No data inserted
|
|
||||||
}
|
|
||||||
|
|
||||||
let results = execute_query(temp_path, "SELECT * FROM test", &[]).unwrap();
|
|
||||||
assert_eq!(results.len(), 0);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
let _ = fs::remove_file(temp_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_execute_query_multiple_named_params() {
|
|
||||||
use rusqlite::Connection;
|
|
||||||
use std::fs;
|
|
||||||
|
|
||||||
let temp_path = "/tmp/test_sqlite_serve_multi.db";
|
|
||||||
let _ = fs::remove_file(temp_path);
|
|
||||||
|
|
||||||
{
|
|
||||||
let conn = Connection::open(temp_path).unwrap();
|
|
||||||
conn.execute("CREATE TABLE books (id INTEGER, genre TEXT, rating REAL)", [])
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO books VALUES
|
|
||||||
(1, 'Fiction', 4.5),
|
|
||||||
(2, 'Science', 4.8),
|
|
||||||
(3, 'Fiction', 4.9),
|
|
||||||
(4, 'Science', 4.2)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test multiple named parameters in different order
|
|
||||||
let params = vec![
|
|
||||||
(":min_rating".to_string(), "4.5".to_string()),
|
|
||||||
(":genre".to_string(), "Fiction".to_string()),
|
|
||||||
];
|
|
||||||
|
|
||||||
let results = execute_query(
|
|
||||||
temp_path,
|
|
||||||
"SELECT * FROM books WHERE genre = :genre AND rating >= :min_rating ORDER BY rating DESC",
|
|
||||||
¶ms,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(results.len(), 2);
|
|
||||||
assert_eq!(results[0].get("rating").unwrap().as_f64().unwrap(), 4.9);
|
|
||||||
assert_eq!(results[1].get("rating").unwrap().as_f64().unwrap(), 4.5);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
let _ = fs::remove_file(temp_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_template_override_behavior() {
|
|
||||||
use std::fs;
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
let temp_dir = "/tmp/test_sqlite_serve_override";
|
|
||||||
let _ = fs::remove_dir_all(temp_dir);
|
|
||||||
fs::create_dir_all(temp_dir).unwrap();
|
|
||||||
|
|
||||||
// Create first template
|
|
||||||
let template1_path = format!("{}/test.hbs", temp_dir);
|
|
||||||
let mut file1 = fs::File::create(&template1_path).unwrap();
|
|
||||||
file1.write_all(b"Original").unwrap();
|
|
||||||
|
|
||||||
let mut reg = Handlebars::new();
|
|
||||||
reg.register_template_file("test", &template1_path).unwrap();
|
|
||||||
|
|
||||||
let rendered1 = reg.render("test", &json!({})).unwrap();
|
|
||||||
assert_eq!(rendered1, "Original");
|
|
||||||
|
|
||||||
// Override with new content
|
|
||||||
let mut file2 = fs::File::create(&template1_path).unwrap();
|
|
||||||
file2.write_all(b"Updated").unwrap();
|
|
||||||
|
|
||||||
// Re-register to override
|
|
||||||
reg.register_template_file("test", &template1_path).unwrap();
|
|
||||||
|
|
||||||
let rendered2 = reg.render("test", &json!({})).unwrap();
|
|
||||||
assert_eq!(rendered2, "Updated");
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
let _ = fs::remove_dir_all(temp_dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
327
src/query.rs
Normal file
327
src/query.rs
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
//! SQL query execution with parameter binding
|
||||||
|
|
||||||
|
use rusqlite::{Connection, Result};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Execute a SQL query with parameters and return results as JSON-compatible data
|
||||||
|
///
|
||||||
|
/// Supports both positional (?) and named (:name) parameters.
|
||||||
|
/// If any parameter has a non-empty name, all parameters are treated as named.
|
||||||
|
pub fn execute_query(
|
||||||
|
db_path: &str,
|
||||||
|
query: &str,
|
||||||
|
params: &[(String, String)], // (param_name, value) pairs
|
||||||
|
) -> Result<Vec<HashMap<String, Value>>> {
|
||||||
|
let conn = Connection::open(db_path)?;
|
||||||
|
let mut stmt = conn.prepare(query)?;
|
||||||
|
|
||||||
|
let column_count = stmt.column_count();
|
||||||
|
let column_names: Vec<String> = (0..column_count)
|
||||||
|
.map(|i| stmt.column_name(i).unwrap_or("").to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Bind parameters (either positional or named)
|
||||||
|
let has_named_params = params.iter().any(|(name, _)| !name.is_empty());
|
||||||
|
|
||||||
|
// Convert row to JSON map
|
||||||
|
let row_to_map = |row: &rusqlite::Row| -> rusqlite::Result<HashMap<String, Value>> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
for (i, col_name) in column_names.iter().enumerate() {
|
||||||
|
let value: Value = match row.get_ref(i)? {
|
||||||
|
rusqlite::types::ValueRef::Null => Value::Null,
|
||||||
|
rusqlite::types::ValueRef::Integer(v) => Value::Number(v.into()),
|
||||||
|
rusqlite::types::ValueRef::Real(v) => {
|
||||||
|
serde_json::Number::from_f64(v)
|
||||||
|
.map(Value::Number)
|
||||||
|
.unwrap_or(Value::Null)
|
||||||
|
}
|
||||||
|
rusqlite::types::ValueRef::Text(v) => {
|
||||||
|
Value::String(String::from_utf8_lossy(v).to_string())
|
||||||
|
}
|
||||||
|
rusqlite::types::ValueRef::Blob(v) => {
|
||||||
|
// Convert blob to hex string
|
||||||
|
let hex_string = v.iter().map(|b| format!("{:02x}", b)).collect::<String>();
|
||||||
|
Value::String(hex_string)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
map.insert(col_name.clone(), value);
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows = if has_named_params {
|
||||||
|
// Use named parameters
|
||||||
|
let named_params: Vec<(&str, &dyn rusqlite::ToSql)> = params
|
||||||
|
.iter()
|
||||||
|
.map(|(name, value)| (name.as_str(), value as &dyn rusqlite::ToSql))
|
||||||
|
.collect();
|
||||||
|
stmt.query_map(named_params.as_slice(), row_to_map)?
|
||||||
|
} else {
|
||||||
|
// Use positional parameters
|
||||||
|
let positional_params: Vec<&dyn rusqlite::ToSql> = params
|
||||||
|
.iter()
|
||||||
|
.map(|(_, value)| value as &dyn rusqlite::ToSql)
|
||||||
|
.collect();
|
||||||
|
stmt.query_map(positional_params.as_slice(), row_to_map)?
|
||||||
|
};
|
||||||
|
|
||||||
|
rows.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_execute_query_empty_db() {
|
||||||
|
// Test with a non-existent database - should return error
|
||||||
|
let result = execute_query("/nonexistent/test.db", "SELECT 1", &[]);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_execute_query_with_memory_db() {
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let temp_path = "/tmp/test_sqlite_serve.db";
|
||||||
|
let _ = fs::remove_file(temp_path);
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = Connection::open(temp_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE test (id INTEGER, name TEXT, value REAL)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO test VALUES (1, 'first', 1.5), (2, 'second', 2.5)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = execute_query(temp_path, "SELECT * FROM test ORDER BY id", &[]).unwrap();
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
results[0].get("id").unwrap(),
|
||||||
|
&Value::Number(1.into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
results[0].get("name").unwrap(),
|
||||||
|
&Value::String("first".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(temp_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_execute_query_with_positional_params() {
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let temp_path = "/tmp/test_sqlite_serve_params.db";
|
||||||
|
let _ = fs::remove_file(temp_path);
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = Connection::open(temp_path).unwrap();
|
||||||
|
conn.execute("CREATE TABLE books (id INTEGER, title TEXT)", [])
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO books VALUES (1, 'Book One'), (2, 'Book Two'), (3, 'Book Three')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = vec![(String::new(), "2".to_string())];
|
||||||
|
let results =
|
||||||
|
execute_query(temp_path, "SELECT * FROM books WHERE id = ?", ¶ms).unwrap();
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
results[0].get("title").unwrap(),
|
||||||
|
&Value::String("Book Two".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(temp_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_execute_query_with_named_params() {
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let temp_path = "/tmp/test_sqlite_serve_named.db";
|
||||||
|
let _ = fs::remove_file(temp_path);
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = Connection::open(temp_path).unwrap();
|
||||||
|
conn.execute("CREATE TABLE books (id INTEGER, title TEXT, year INTEGER)", [])
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO books VALUES (1, 'Old Book', 2000), (2, 'New Book', 2020), (3, 'Newer Book', 2023)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = vec![
|
||||||
|
(":min_year".to_string(), "2015".to_string()),
|
||||||
|
(":max_year".to_string(), "2024".to_string()),
|
||||||
|
];
|
||||||
|
let results = execute_query(
|
||||||
|
temp_path,
|
||||||
|
"SELECT * FROM books WHERE year >= :min_year AND year <= :max_year ORDER BY year",
|
||||||
|
¶ms,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
results[0].get("title").unwrap(),
|
||||||
|
&Value::String("New Book".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
results[1].get("title").unwrap(),
|
||||||
|
&Value::String("Newer Book".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(temp_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_execute_query_data_types() {
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let temp_path = "/tmp/test_sqlite_serve_types.db";
|
||||||
|
let _ = fs::remove_file(temp_path);
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = Connection::open(temp_path).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE types (id INTEGER, name TEXT, price REAL, data BLOB, nullable TEXT)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO types VALUES (42, 'test', 3.14, X'DEADBEEF', NULL)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = execute_query(temp_path, "SELECT * FROM types", &[]).unwrap();
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
|
||||||
|
let row = &results[0];
|
||||||
|
|
||||||
|
assert_eq!(row.get("id").unwrap(), &Value::Number(42.into()));
|
||||||
|
assert_eq!(
|
||||||
|
row.get("name").unwrap(),
|
||||||
|
&Value::String("test".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(row.get("price").unwrap().as_f64().unwrap(), 3.14);
|
||||||
|
assert_eq!(
|
||||||
|
row.get("data").unwrap(),
|
||||||
|
&Value::String("deadbeef".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(row.get("nullable").unwrap(), &Value::Null);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(temp_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_execute_query_multiple_named_params() {
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let temp_path = "/tmp/test_sqlite_serve_multi.db";
|
||||||
|
let _ = fs::remove_file(temp_path);
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = Connection::open(temp_path).unwrap();
|
||||||
|
conn.execute("CREATE TABLE books (id INTEGER, genre TEXT, rating REAL)", [])
|
||||||
|
.unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO books VALUES
|
||||||
|
(1, 'Fiction', 4.5),
|
||||||
|
(2, 'Science', 4.8),
|
||||||
|
(3, 'Fiction', 4.9),
|
||||||
|
(4, 'Science', 4.2)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = vec![
|
||||||
|
(":min_rating".to_string(), "4.5".to_string()),
|
||||||
|
(":genre".to_string(), "Fiction".to_string()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let results = execute_query(
|
||||||
|
temp_path,
|
||||||
|
"SELECT * FROM books WHERE genre = :genre AND rating >= :min_rating ORDER BY rating DESC",
|
||||||
|
¶ms,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert_eq!(results[0].get("rating").unwrap().as_f64().unwrap(), 4.9);
|
||||||
|
assert_eq!(results[1].get("rating").unwrap().as_f64().unwrap(), 4.5);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(temp_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_execute_query_with_like_operator() {
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let temp_path = "/tmp/test_sqlite_serve_like.db";
|
||||||
|
let _ = fs::remove_file(temp_path);
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = Connection::open(temp_path).unwrap();
|
||||||
|
conn.execute("CREATE TABLE books (title TEXT)", []).unwrap();
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO books VALUES ('The Rust Book'), ('Clean Code'), ('Rust in Action')",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = vec![(":search".to_string(), "Rust".to_string())];
|
||||||
|
let results = execute_query(
|
||||||
|
temp_path,
|
||||||
|
"SELECT * FROM books WHERE title LIKE '%' || :search || '%'",
|
||||||
|
¶ms,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(temp_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_execute_query_empty_results() {
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let temp_path = "/tmp/test_sqlite_serve_empty.db";
|
||||||
|
let _ = fs::remove_file(temp_path);
|
||||||
|
|
||||||
|
{
|
||||||
|
let conn = Connection::open(temp_path).unwrap();
|
||||||
|
conn.execute("CREATE TABLE test (id INTEGER)", []).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let results = execute_query(temp_path, "SELECT * FROM test", &[]).unwrap();
|
||||||
|
assert_eq!(results.len(), 0);
|
||||||
|
|
||||||
|
let _ = fs::remove_file(temp_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
160
src/template.rs
Normal file
160
src/template.rs
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
//! Template loading and management
|
||||||
|
|
||||||
|
use handlebars::Handlebars;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Load all .hbs templates from a directory into the Handlebars registry
|
||||||
|
///
|
||||||
|
/// Each template is registered by its filename (without .hbs extension).
|
||||||
|
/// Returns the number of templates successfully loaded.
|
||||||
|
pub fn load_templates_from_dir(reg: &mut Handlebars, dir_path: &str) -> std::io::Result<usize> {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let dir = Path::new(dir_path);
|
||||||
|
if !dir.exists() || !dir.is_dir() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut count = 0;
|
||||||
|
for entry in fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
|
||||||
|
if path.is_file() {
|
||||||
|
if let Some(ext) = path.extension() {
|
||||||
|
if ext == "hbs" {
|
||||||
|
if let Some(stem) = path.file_stem() {
|
||||||
|
if let Some(name) = stem.to_str() {
|
||||||
|
if let Err(e) = reg.register_template_file(name, &path) {
|
||||||
|
eprintln!("Failed to register template {}: {}", path.display(), e);
|
||||||
|
} else {
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_templates_from_nonexistent_dir() {
|
||||||
|
let mut reg = Handlebars::new();
|
||||||
|
let result = load_templates_from_dir(&mut reg, "/nonexistent/path/to/templates");
|
||||||
|
|
||||||
|
// Should succeed but load 0 templates
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(result.unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_load_templates_from_dir() {
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
let temp_dir = "/tmp/test_sqlite_serve_templates";
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
fs::create_dir_all(temp_dir).unwrap();
|
||||||
|
|
||||||
|
// Create test templates
|
||||||
|
let mut file1 = fs::File::create(format!("{}/template1.hbs", temp_dir)).unwrap();
|
||||||
|
file1.write_all(b"<h1>Template 1</h1>").unwrap();
|
||||||
|
|
||||||
|
let mut file2 = fs::File::create(format!("{}/template2.hbs", temp_dir)).unwrap();
|
||||||
|
file2.write_all(b"<h1>Template 2</h1>").unwrap();
|
||||||
|
|
||||||
|
// Create a non-template file (should be ignored)
|
||||||
|
let mut file3 = fs::File::create(format!("{}/readme.txt", temp_dir)).unwrap();
|
||||||
|
file3.write_all(b"Not a template").unwrap();
|
||||||
|
|
||||||
|
let mut reg = Handlebars::new();
|
||||||
|
let count = load_templates_from_dir(&mut reg, temp_dir).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(count, 2);
|
||||||
|
assert!(reg.has_template("template1"));
|
||||||
|
assert!(reg.has_template("template2"));
|
||||||
|
assert!(!reg.has_template("readme"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_template_rendering_with_results() {
|
||||||
|
use serde_json::json;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
let temp_dir = "/tmp/test_sqlite_serve_render";
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
fs::create_dir_all(temp_dir).unwrap();
|
||||||
|
|
||||||
|
let template_path = format!("{}/list.hbs", temp_dir);
|
||||||
|
let mut file = fs::File::create(&template_path).unwrap();
|
||||||
|
file.write_all(b"{{#each results}}<li>{{name}}</li>{{/each}}")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut reg = Handlebars::new();
|
||||||
|
reg.register_template_file("list", &template_path).unwrap();
|
||||||
|
|
||||||
|
let mut results = vec![];
|
||||||
|
let mut item1 = std::collections::HashMap::new();
|
||||||
|
item1.insert(
|
||||||
|
"name".to_string(),
|
||||||
|
serde_json::Value::String("Item 1".to_string()),
|
||||||
|
);
|
||||||
|
results.push(item1);
|
||||||
|
|
||||||
|
let mut item2 = std::collections::HashMap::new();
|
||||||
|
item2.insert(
|
||||||
|
"name".to_string(),
|
||||||
|
serde_json::Value::String("Item 2".to_string()),
|
||||||
|
);
|
||||||
|
results.push(item2);
|
||||||
|
|
||||||
|
let rendered = reg.render("list", &json!({"results": results})).unwrap();
|
||||||
|
assert!(rendered.contains("<li>Item 1</li>"));
|
||||||
|
assert!(rendered.contains("<li>Item 2</li>"));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_template_override_behavior() {
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
let temp_dir = "/tmp/test_sqlite_serve_override";
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
fs::create_dir_all(temp_dir).unwrap();
|
||||||
|
|
||||||
|
let template1_path = format!("{}/test.hbs", temp_dir);
|
||||||
|
let mut file1 = fs::File::create(&template1_path).unwrap();
|
||||||
|
file1.write_all(b"Original").unwrap();
|
||||||
|
|
||||||
|
let mut reg = Handlebars::new();
|
||||||
|
reg.register_template_file("test", &template1_path).unwrap();
|
||||||
|
|
||||||
|
let rendered1 = reg.render("test", &serde_json::json!({})).unwrap();
|
||||||
|
assert_eq!(rendered1, "Original");
|
||||||
|
|
||||||
|
// Override with new content
|
||||||
|
let mut file2 = fs::File::create(&template1_path).unwrap();
|
||||||
|
file2.write_all(b"Updated").unwrap();
|
||||||
|
|
||||||
|
// Re-register to override
|
||||||
|
reg.register_template_file("test", &template1_path).unwrap();
|
||||||
|
|
||||||
|
let rendered2 = reg.render("test", &serde_json::json!({})).unwrap();
|
||||||
|
assert_eq!(rendered2, "Updated");
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(temp_dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
107
src/variable.rs
Normal file
107
src/variable.rs
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
//! Nginx variable resolution utilities
|
||||||
|
|
||||||
|
use ngx::ffi::{ngx_hash_key, ngx_http_get_variable, ngx_str_t};
|
||||||
|
use ngx::http::Request;
|
||||||
|
use ngx::ngx_log_debug_http;
|
||||||
|
|
||||||
|
/// Resolve a variable name (with $ prefix) or return literal value
|
||||||
|
///
|
||||||
|
/// If var_name starts with '$', resolves it as an nginx variable.
|
||||||
|
/// Otherwise, returns var_name as a literal string.
|
||||||
|
pub fn resolve_variable(request: &mut Request, var_name: &str) -> Result<String, String> {
|
||||||
|
if var_name.starts_with('$') {
|
||||||
|
resolve_nginx_variable(request, var_name)
|
||||||
|
} else {
|
||||||
|
Ok(var_name.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve an nginx variable by name
|
||||||
|
fn resolve_nginx_variable(request: &mut Request, var_name: &str) -> Result<String, String> {
|
||||||
|
let var_name_str = &var_name[1..]; // Remove the '$' prefix
|
||||||
|
let var_name_bytes = var_name_str.as_bytes();
|
||||||
|
|
||||||
|
let mut name = ngx_str_t {
|
||||||
|
len: var_name_bytes.len(),
|
||||||
|
data: var_name_bytes.as_ptr() as *mut u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
let key = unsafe { ngx_hash_key(name.data, name.len) };
|
||||||
|
let r: *mut ngx::ffi::ngx_http_request_t = request.into();
|
||||||
|
let var_value = unsafe { ngx_http_get_variable(r, &mut name, key) };
|
||||||
|
|
||||||
|
if var_value.is_null() {
|
||||||
|
ngx_log_debug_http!(request, "variable not found: {}", var_name);
|
||||||
|
return Err(format!("variable not found: {}", var_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
let var_ref = unsafe { &*var_value };
|
||||||
|
if var_ref.valid() == 0 {
|
||||||
|
ngx_log_debug_http!(request, "variable value not valid: {}", var_name);
|
||||||
|
return Err(format!("variable not valid: {}", var_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
match std::str::from_utf8(var_ref.as_bytes()) {
|
||||||
|
Ok(s) => Ok(s.to_string()),
|
||||||
|
Err(_) => {
|
||||||
|
ngx_log_debug_http!(request, "failed to decode variable as UTF-8: {}", var_name);
|
||||||
|
Err(format!("invalid UTF-8 in variable: {}", var_name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_literal_value() {
|
||||||
|
// Non-$ prefixed values should be returned as-is
|
||||||
|
// Note: This test doesn't need a real request since it's just a literal
|
||||||
|
let result = "literal_value";
|
||||||
|
assert_eq!(result, "literal_value");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_has_named_params() {
|
||||||
|
let positional = vec![
|
||||||
|
(String::new(), "value1".to_string()),
|
||||||
|
(String::new(), "value2".to_string()),
|
||||||
|
];
|
||||||
|
assert!(!positional.iter().any(|(name, _)| !name.is_empty()));
|
||||||
|
|
||||||
|
let named = vec![
|
||||||
|
(":id".to_string(), "value1".to_string()),
|
||||||
|
(":name".to_string(), "value2".to_string()),
|
||||||
|
];
|
||||||
|
assert!(named.iter().any(|(name, _)| !name.is_empty()));
|
||||||
|
|
||||||
|
let mixed = vec![
|
||||||
|
(":id".to_string(), "value1".to_string()),
|
||||||
|
(String::new(), "value2".to_string()),
|
||||||
|
];
|
||||||
|
assert!(mixed.iter().any(|(name, _)| !name.is_empty()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_named_params_parsing() {
|
||||||
|
// Test parameter name parsing logic
|
||||||
|
let test_cases = vec![
|
||||||
|
(2, false, ""), // sqlite_param $arg_id
|
||||||
|
(3, true, ":book_id"), // sqlite_param :book_id $arg_id
|
||||||
|
];
|
||||||
|
|
||||||
|
for (nelts, expected_is_named, expected_param_name) in test_cases {
|
||||||
|
if nelts == 2 {
|
||||||
|
let param_name = String::new();
|
||||||
|
assert!(!expected_is_named);
|
||||||
|
assert_eq!(param_name, expected_param_name);
|
||||||
|
} else if nelts == 3 {
|
||||||
|
let param_name = ":book_id".to_string();
|
||||||
|
assert!(expected_is_named);
|
||||||
|
assert_eq!(param_name, expected_param_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user