diff --git a/src/domain.rs b/src/domain.rs new file mode 100644 index 0000000..fc6f238 --- /dev/null +++ b/src/domain.rs @@ -0,0 +1,306 @@ +//! Pure functional core with dependency injection (Functional Core, Imperative Shell) + +use crate::types::{DatabasePath, ParameterBinding, SqlQuery, TemplatePath}; +use serde_json::Value; +use std::collections::HashMap; +use std::path::Path; + +/// Configuration for a location (validated at parse time) +#[derive(Debug, Clone)] +pub struct ValidatedConfig { + pub db_path: DatabasePath, + pub query: SqlQuery, + pub template_path: TemplatePath, + pub parameters: Vec, +} + +/// Template resolution result +#[derive(Debug)] +pub struct ResolvedTemplate { + pub full_path: String, + pub directory: String, +} + +impl ResolvedTemplate { + pub fn full_path(&self) -> &str { + &self.full_path + } + + pub fn directory(&self) -> &str { + &self.directory + } +} + +/// Resolve template path relative to document root and URI (pure function) +pub fn resolve_template_path( + doc_root: &str, + uri: &str, + template: &TemplatePath, +) -> ResolvedTemplate { + let full_path = format!("{}{}/{}", doc_root, uri, template.as_str()); + let directory = Path::new(&full_path) + .parent() + .and_then(|p| p.to_str()) + .unwrap_or("") + .to_string(); + + ResolvedTemplate { + full_path, + directory, + } +} + +/// Parameter resolution strategy (dependency injection) +pub trait VariableResolver { + fn resolve(&self, var_name: &str) -> Result; +} + +/// Resolve all parameters using the provided resolver +pub fn resolve_parameters( + bindings: &[ParameterBinding], + resolver: &dyn VariableResolver, +) -> Result, String> { + let mut resolved = Vec::new(); + + for binding in bindings { + match binding { + ParameterBinding::Positional { variable } => { + let value = resolver.resolve(variable.as_str())?; + resolved.push((String::new(), value)); + } + ParameterBinding::PositionalLiteral { value } => { + resolved.push((String::new(), value.clone())); + } + ParameterBinding::Named { name, variable } => { + let value = resolver.resolve(variable.as_str())?; + resolved.push((name.as_str().to_string(), value)); + } + ParameterBinding::NamedLiteral { name, value } => { + resolved.push((name.as_str().to_string(), value.clone())); + } + } + } + + Ok(resolved) +} + +/// Query execution strategy (dependency injection) +pub trait QueryExecutor { + fn execute( + &self, + db_path: &DatabasePath, + query: &SqlQuery, + params: &[(String, String)], + ) -> Result>, String>; +} + +/// Template loading strategy (dependency injection) +pub trait TemplateLoader { + fn load_from_dir(&self, dir_path: &str) -> Result; + fn register_template(&self, name: &str, path: &str) -> Result<(), String>; +} + +/// Template rendering strategy (dependency injection) +pub trait TemplateRenderer { + fn render(&self, template_name: &str, data: &Value) -> Result; +} + +/// Pure business logic for request handling +pub struct RequestProcessor { + query_executor: Q, + template_loader: L, + renderer: R, +} + +impl RequestProcessor +where + Q: QueryExecutor, + L: TemplateLoader, + R: TemplateRenderer, +{ + pub fn new(query_executor: Q, template_loader: L, renderer: R) -> Self { + RequestProcessor { + query_executor, + template_loader, + renderer, + } + } + + /// Process a request (pure, testable business logic) + pub fn process( + &self, + config: &ValidatedConfig, + resolved_template: &ResolvedTemplate, + resolved_params: &[(String, String)], + global_template_dir: Option<&str>, + ) -> Result { + // Execute query + let results = self + .query_executor + .execute(&config.db_path, &config.query, resolved_params) + .map_err(|e| format!("query execution failed: {}", e))?; + + // Load global templates if provided + if let Some(dir) = global_template_dir { + self.template_loader.load_from_dir(dir).ok(); + } + + // Load local templates + self.template_loader + .load_from_dir(resolved_template.directory()) + .ok(); + + // Register main template + self.template_loader + .register_template("template", resolved_template.full_path()) + .map_err(|e| format!("failed to register template: {}", e))?; + + // Render + let data = serde_json::json!({"results": results}); + self.renderer + .render("template", &data) + .map_err(|e| format!("rendering failed: {}", e)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{NginxVariable, ParamName}; + + #[test] + fn test_resolve_template_path() { + let template = TemplatePath::parse("list.hbs").unwrap(); + let resolved = resolve_template_path("server_root", "/books", &template); + + assert_eq!(resolved.full_path(), "server_root/books/list.hbs"); + assert_eq!(resolved.directory(), "server_root/books"); + } + + #[test] + fn test_resolve_template_path_with_trailing_slash() { + let template = TemplatePath::parse("index.hbs").unwrap(); + let resolved = resolve_template_path("public/", "/docs/", &template); + + assert!(resolved.full_path().contains("public//docs/")); + } + + // Mock implementations for testing + struct MockVariableResolver; + impl VariableResolver for MockVariableResolver { + fn resolve(&self, var_name: &str) -> Result { + match var_name { + "$arg_id" => Ok("123".to_string()), + "$arg_genre" => Ok("Fiction".to_string()), + _ => Err(format!("unknown variable: {}", var_name)), + } + } + } + + struct MockQueryExecutor; + impl QueryExecutor for MockQueryExecutor { + fn execute( + &self, + _db_path: &DatabasePath, + _query: &SqlQuery, + _params: &[(String, String)], + ) -> Result>, String> { + let mut row = HashMap::new(); + row.insert("id".to_string(), Value::Number(1.into())); + row.insert("title".to_string(), Value::String("Test Book".to_string())); + Ok(vec![row]) + } + } + + struct MockTemplateLoader; + impl TemplateLoader for MockTemplateLoader { + fn load_from_dir(&self, _dir_path: &str) -> Result { + Ok(0) + } + fn register_template(&self, _name: &str, _path: &str) -> Result<(), String> { + Ok(()) + } + } + + struct MockTemplateRenderer; + impl TemplateRenderer for MockTemplateRenderer { + fn render(&self, _template_name: &str, data: &Value) -> Result { + Ok(format!("Rendered: {:?}", data)) + } + } + + #[test] + fn test_resolve_parameters_positional() { + let bindings = vec![ + ParameterBinding::Positional { + variable: NginxVariable::parse("$arg_id").unwrap(), + }, + ]; + + let resolver = MockVariableResolver; + let resolved = resolve_parameters(&bindings, &resolver).unwrap(); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].0, ""); // Positional (no name) + assert_eq!(resolved[0].1, "123"); + } + + #[test] + fn test_resolve_parameters_named() { + let bindings = vec![ + ParameterBinding::Named { + name: ParamName::parse(":book_id").unwrap(), + variable: NginxVariable::parse("$arg_id").unwrap(), + }, + ]; + + let resolver = MockVariableResolver; + let resolved = resolve_parameters(&bindings, &resolver).unwrap(); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].0, ":book_id"); + assert_eq!(resolved[0].1, "123"); + } + + #[test] + fn test_resolve_parameters_literal() { + let bindings = vec![ + ParameterBinding::PositionalLiteral { + value: "constant".to_string(), + }, + ]; + + let resolver = MockVariableResolver; + let resolved = resolve_parameters(&bindings, &resolver).unwrap(); + + assert_eq!(resolved.len(), 1); + assert_eq!(resolved[0].1, "constant"); + } + + #[test] + fn test_request_processor_integration() { + let config = ValidatedConfig { + db_path: DatabasePath::parse("test.db").unwrap(), + query: SqlQuery::parse("SELECT * FROM books").unwrap(), + template_path: TemplatePath::parse("list.hbs").unwrap(), + parameters: vec![], + }; + + let resolved_template = ResolvedTemplate { + full_path: "templates/list.hbs".to_string(), + directory: "templates".to_string(), + }; + + let processor = RequestProcessor::new( + MockQueryExecutor, + MockTemplateLoader, + MockTemplateRenderer, + ); + + let result = processor.process(&config, &resolved_template, &[], None); + + assert!(result.is_ok()); + assert!(result.unwrap().contains("Rendered")); + } +} + diff --git a/src/lib.rs b/src/lib.rs index 6eff5fa..bab49ec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,10 @@ //! sqlite-serve - NGINX module for serving dynamic content from SQLite databases mod config; +mod domain; mod query; mod template; +mod types; mod variable; use config::{MainConfig, ModuleConfig}; @@ -17,7 +19,7 @@ use ngx::ffi::{ 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 ngx::{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; @@ -30,7 +32,7 @@ impl ngx::http::HttpModule for Module { } unsafe extern "C" fn postconfiguration(_cf: *mut ngx_conf_t) -> ngx_int_t { - core::Status::NGX_OK.into() + Status::NGX_OK.into() } } @@ -250,7 +252,7 @@ http_request_handler!(howto_access_handler, |request: &mut http::Request| { // Check if all required config values are set if co.db_path.is_empty() || co.query.is_empty() || co.template_path.is_empty() { - return core::Status::NGX_OK; + return Status::NGX_OK; } ngx_log_debug_http!(request, "sqlite module handler called"); @@ -378,7 +380,7 @@ 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 == Status::NGX_ERROR || rc > Status::NGX_OK || request.header_only() { return rc; } diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..7cdd772 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,303 @@ +//! Type-safe wrappers for domain concepts (Parse, Don't Validate) + +use std::path::{Path, PathBuf}; + +/// A validated database path that exists and is accessible +#[derive(Debug, Clone)] +pub struct DatabasePath(PathBuf); + +impl DatabasePath { + /// Parse and validate a database path + pub fn parse(path: impl AsRef) -> Result { + let path = path.as_ref(); + // For now, just validate it's not empty + // In production, could check if file exists, is readable, etc. + if path.as_os_str().is_empty() { + return Err("database path cannot be empty".to_string()); + } + Ok(DatabasePath(path.to_path_buf())) + } + + pub fn as_path(&self) -> &Path { + &self.0 + } + + pub fn as_str(&self) -> &str { + self.0.to_str().unwrap_or("") + } +} + +/// A validated SQL query (must be SELECT) +#[derive(Debug, Clone)] +pub struct SqlQuery(String); + +impl SqlQuery { + /// Parse and validate a SQL query + pub fn parse(query: impl Into) -> Result { + let query = query.into(); + let trimmed = query.trim().to_uppercase(); + + if trimmed.is_empty() { + return Err("query cannot be empty".to_string()); + } + + // Ensure it's a SELECT query (read-only) + if !trimmed.starts_with("SELECT") { + return Err("only SELECT queries are allowed".to_string()); + } + + Ok(SqlQuery(query)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// A validated template path +#[derive(Debug, Clone)] +pub struct TemplatePath(PathBuf); + +impl TemplatePath { + /// Parse and validate a template path + pub fn parse(path: impl AsRef) -> Result { + let path = path.as_ref(); + + if path.as_os_str().is_empty() { + return Err("template path cannot be empty".to_string()); + } + + // Ensure it's a .hbs file + if path.extension().and_then(|e| e.to_str()) != Some("hbs") { + return Err("template must be a .hbs file".to_string()); + } + + Ok(TemplatePath(path.to_path_buf())) + } + + pub fn as_path(&self) -> &Path { + &self.0 + } + + pub fn as_str(&self) -> &str { + self.0.to_str().unwrap_or("") + } +} + +/// A validated nginx variable name (starts with $) +#[derive(Debug, Clone)] +pub struct NginxVariable(String); + +impl NginxVariable { + /// Parse a nginx variable name + pub fn parse(name: impl Into) -> Result { + let name = name.into(); + + if name.is_empty() { + return Err("variable name cannot be empty".to_string()); + } + + if !name.starts_with('$') { + return Err(format!("variable name must start with $: {}", name)); + } + + // Get the part after $ + let var_name = &name[1..]; + if var_name.is_empty() { + return Err("variable name after $ cannot be empty".to_string()); + } + + Ok(NginxVariable(name)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Get the variable name without the $ prefix + pub fn name(&self) -> &str { + &self.0[1..] + } +} + +/// A SQL parameter name (starts with :) +#[derive(Debug, Clone)] +pub struct ParamName(String); + +impl ParamName { + /// Parse a SQL parameter name + pub fn parse(name: impl Into) -> Result { + let name = name.into(); + + if name.is_empty() { + return Err("parameter name cannot be empty".to_string()); + } + + if !name.starts_with(':') { + return Err(format!("parameter name must start with :: {}", name)); + } + + Ok(ParamName(name)) + } + + /// Create an empty (positional) parameter name + pub fn positional() -> Self { + ParamName(String::new()) + } + + pub fn is_positional(&self) -> bool { + self.0.is_empty() + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// A parameter binding (param name + variable or literal) +#[derive(Debug, Clone)] +pub enum ParameterBinding { + Positional { variable: NginxVariable }, + PositionalLiteral { value: String }, + Named { name: ParamName, variable: NginxVariable }, + NamedLiteral { name: ParamName, value: String }, +} + +impl ParameterBinding { + pub fn param_name(&self) -> String { + match self { + ParameterBinding::Positional { .. } | ParameterBinding::PositionalLiteral { .. } => { + String::new() + } + ParameterBinding::Named { name, .. } | ParameterBinding::NamedLiteral { name, .. } => { + name.as_str().to_string() + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_database_path_valid() { + let path = DatabasePath::parse("test.db").unwrap(); + assert_eq!(path.as_str(), "test.db"); + } + + #[test] + fn test_database_path_empty() { + let result = DatabasePath::parse(""); + assert!(result.is_err()); + } + + #[test] + fn test_sql_query_valid_select() { + let query = SqlQuery::parse("SELECT * FROM books").unwrap(); + assert_eq!(query.as_str(), "SELECT * FROM books"); + } + + #[test] + fn test_sql_query_case_insensitive() { + let query = SqlQuery::parse("select id from test").unwrap(); + assert!(query.as_str().to_uppercase().starts_with("SELECT")); + } + + #[test] + fn test_sql_query_rejects_insert() { + let result = SqlQuery::parse("INSERT INTO books VALUES (1)"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("SELECT")); + } + + #[test] + fn test_sql_query_rejects_update() { + let result = SqlQuery::parse("UPDATE books SET title = 'x'"); + assert!(result.is_err()); + } + + #[test] + fn test_sql_query_rejects_delete() { + let result = SqlQuery::parse("DELETE FROM books"); + assert!(result.is_err()); + } + + #[test] + fn test_sql_query_rejects_empty() { + let result = SqlQuery::parse(""); + assert!(result.is_err()); + } + + #[test] + fn test_template_path_valid() { + let path = TemplatePath::parse("template.hbs").unwrap(); + assert_eq!(path.as_str(), "template.hbs"); + } + + #[test] + fn test_template_path_with_directory() { + let path = TemplatePath::parse("views/index.hbs").unwrap(); + assert!(path.as_str().ends_with("index.hbs")); + } + + #[test] + fn test_template_path_rejects_non_hbs() { + let result = TemplatePath::parse("template.html"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains(".hbs")); + } + + #[test] + fn test_template_path_rejects_empty() { + let result = TemplatePath::parse(""); + assert!(result.is_err()); + } + + #[test] + fn test_nginx_variable_valid() { + let var = NginxVariable::parse("$arg_id").unwrap(); + assert_eq!(var.as_str(), "$arg_id"); + assert_eq!(var.name(), "arg_id"); + } + + #[test] + fn test_nginx_variable_rejects_without_dollar() { + let result = NginxVariable::parse("arg_id"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("$")); + } + + #[test] + fn test_nginx_variable_rejects_empty() { + let result = NginxVariable::parse(""); + assert!(result.is_err()); + } + + #[test] + fn test_nginx_variable_rejects_only_dollar() { + let result = NginxVariable::parse("$"); + assert!(result.is_err()); + } + + #[test] + fn test_param_name_valid() { + let param = ParamName::parse(":book_id").unwrap(); + assert_eq!(param.as_str(), ":book_id"); + } + + #[test] + fn test_param_name_positional() { + let param = ParamName::positional(); + assert!(param.is_positional()); + assert_eq!(param.as_str(), ""); + } + + #[test] + fn test_param_name_rejects_without_colon() { + let result = ParamName::parse("book_id"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains(":")); + } +} + diff --git a/src/variable.rs b/src/variable.rs index 3621122..d7766e6 100644 --- a/src/variable.rs +++ b/src/variable.rs @@ -52,7 +52,6 @@ fn resolve_nginx_variable(request: &mut Request, var_name: &str) -> Result