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:
Edward Langley
2025-11-15 15:38:20 -08:00
parent 4acce07823
commit da38aba509
5 changed files with 772 additions and 740 deletions

133
src/config.rs Normal file
View 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");
}
}

View File

@ -1,26 +1,30 @@
use handlebars::Handlebars;
use ngx::core::Buffer;
//! sqlite-serve - NGINX module for serving dynamic content from SQLite databases
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::{
ngx_hash_key, ngx_http_get_variable, NGX_CONF_TAKE1, NGX_CONF_TAKE2, NGX_HTTP_LOC_CONF,
NGX_HTTP_MAIN_CONF, NGX_HTTP_MODULE, NGX_HTTP_LOC_CONF_OFFSET, NGX_RS_MODULE_SIGNATURE,
nginx_version, ngx_chain_t, ngx_command_t, ngx_conf_t, ngx_http_module_t, ngx_int_t,
ngx_module_t, ngx_str_t, ngx_uint_t,
NGX_CONF_TAKE1, NGX_CONF_TAKE2, NGX_HTTP_LOC_CONF, NGX_HTTP_MAIN_CONF, NGX_HTTP_MODULE,
NGX_HTTP_LOC_CONF_OFFSET, NGX_RS_MODULE_SIGNATURE, nginx_version, ngx_command_t, ngx_conf_t,
ngx_http_module_t, ngx_int_t, ngx_module_t, ngx_str_t, ngx_uint_t,
};
use ngx::http::{
HttpModule, HttpModuleLocationConf, HttpModuleMainConf, MergeConfigError, NgxHttpCoreModule,
};
use ngx::{core, core::Status, http};
use ngx::{http_request_handler, ngx_log_debug_http, ngx_modules, ngx_string};
use rusqlite::{Connection, Result};
use ngx::core::Buffer;
use ngx::ffi::ngx_chain_t;
use ngx::http::{HttpModule, HttpModuleLocationConf, HttpModuleMainConf, NgxHttpCoreModule};
use ngx::{core, core::Status, http, http_request_handler, ngx_log_debug_http, ngx_modules, ngx_string};
use serde_json::json;
use std::os::raw::{c_char, c_void};
use std::ptr::addr_of;
struct Module;
pub struct Module;
// Implement our HttpModule trait, we're creating a postconfiguration method to install our
// handler's Access phase function.
impl http::HttpModule for Module {
impl ngx::http::HttpModule for Module {
fn module() -> &'static ngx_module_t {
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 {
type LocationConf = ModuleConfig;
}
// Implement HttpModuleMainConf to define our global configuration
unsafe impl HttpModuleMainConf for Module {
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)]
#[allow(non_upper_case_globals)]
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),
};
// 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);
#[unsafe(no_mangle)]
@ -140,8 +90,6 @@ pub static mut ngx_http_howto_module: ngx_module_t = ngx_module_t {
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)]
#[allow(non_upper_case_globals)]
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"),
type_: (NGX_HTTP_MAIN_CONF | NGX_CONF_TAKE1) as ngx_uint_t,
set: Some(ngx_http_howto_commands_set_global_templates),
conf: 0, // Main conf offset
conf: 0,
offset: 0,
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)]
extern "C" fn ngx_http_howto_commands_set_global_templates(
cf: *mut ngx_conf_t,
@ -213,6 +162,7 @@ extern "C" fn ngx_http_howto_commands_set_global_templates(
std::ptr::null_mut()
}
/// Directive handler for sqlite_db
#[unsafe(no_mangle)]
extern "C" fn ngx_http_howto_commands_set_db_path(
cf: *mut ngx_conf_t,
@ -228,6 +178,7 @@ extern "C" fn ngx_http_howto_commands_set_db_path(
std::ptr::null_mut()
}
/// Directive handler for sqlite_query
#[unsafe(no_mangle)]
extern "C" fn ngx_http_howto_commands_set_query(
cf: *mut ngx_conf_t,
@ -243,6 +194,7 @@ extern "C" fn ngx_http_howto_commands_set_query(
std::ptr::null_mut()
}
/// Directive handler for sqlite_template
#[unsafe(no_mangle)]
extern "C" fn ngx_http_howto_commands_set_template_path(
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 args = (*(*cf).args).elts as *mut ngx_str_t;
conf.template_path = (*args.add(1)).to_string();
// Set the content handler for this location
let clcf = NgxHttpCoreModule::location_conf_mut(&*cf)
.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()
}
/// Directive handler for sqlite_param
#[unsafe(no_mangle)]
extern "C" fn ngx_http_howto_commands_add_param(
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 args = (*(*cf).args).elts as *mut ngx_str_t;
let nelts = (*(*cf).args).nelts;
if nelts == 2 {
// Single argument: positional parameter
// sqlite_param $arg_id
@ -291,111 +244,7 @@ extern "C" fn ngx_http_howto_commands_add_param(
std::ptr::null_mut()
}
// Load all .hbs templates from a directory into the Handlebars registry
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 - processes SQLite queries and renders templates
http_request_handler!(howto_access_handler, |request: &mut http::Request| {
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
let mut param_values: Vec<(String, String)> = Vec::new();
for (param_name, var_name) in &co.query_params {
let value = if var_name.starts_with('$') {
// It's a variable reference, resolve it from nginx
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);
match resolve_variable(request, var_name) {
Ok(value) => {
param_values.push((param_name.clone(), value));
}
Err(_) => {
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!(
@ -491,14 +311,19 @@ http_request_handler!(howto_access_handler, |request: &mut http::Request| {
};
// Setup Handlebars and load templates
let mut reg = Handlebars::new();
let mut reg = handlebars::Handlebars::new();
// First, load global templates if configured
let main_conf = Module::main_conf(request).expect("main config is none");
if !main_conf.global_templates_dir.is_empty() {
match load_templates_from_dir(&mut reg, &main_conf.global_templates_dir) {
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) => {
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) {
Ok(_) => {
ngx_log_debug_http!(request, "registered main template: {}", template_full_path);
},
}
Err(e) => {
ngx_log_debug_http!(request, "failed to load main template: {}", e);
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.set_status(http::HTTPStatus::OK);
let rc = request.send_header();
if rc == core::Status::NGX_ERROR
|| rc > core::Status::NGX_OK
|| request.header_only()
{
if rc == core::Status::NGX_ERROR || rc > core::Status::NGX_OK || request.header_only() {
return rc;
}
request.output_filter(&mut out);
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 = ?", &params).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",
&params,
)
.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 || '%'",
&params,
)
.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",
&params,
)
.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
View 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 = ?", &params).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",
&params,
)
.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",
&params,
)
.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 || '%'",
&params,
)
.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
View 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
View 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);
}
}
}
}