Files
harmony/opnsense-codegen/src/controller_parser.rs
2026-05-20 12:03:19 -04:00

380 lines
13 KiB
Rust

//! Minimal PHP controller parser for OPNsense API controllers.
//!
//! Extracts the module path, controller name, model binding, and available
//! CRUD actions from PHP source files using regex — no PHP AST required.
use heck::ToSnakeCase;
use regex::Regex;
use serde::Serialize;
use std::path::Path;
/// Parsed representation of an OPNsense API controller.
#[derive(Debug, Clone, Serialize)]
pub struct ControllerIR {
/// API module path segment (e.g., "firewall")
pub module: String,
/// API controller path segment (e.g., "d_nat")
pub controller: String,
/// Model name used as JSON envelope key (e.g., "DNat")
pub model_name: String,
/// Full PHP model class path (e.g., "OPNsense\\Firewall\\DNat")
pub model_class: String,
/// Parent controller class (e.g., "FilterBaseController")
pub parent_class: String,
/// Discovered CRUD entities with their available actions
pub entities: Vec<EntityIR>,
/// Standalone actions (apply, reconfigure, etc.)
pub standalone_actions: Vec<String>,
}
/// A CRUD entity exposed by the controller.
#[derive(Debug, Clone, Serialize)]
pub struct EntityIR {
/// Entity name as it appears in URLs (e.g., "Rule", "Item", "Host")
pub name: String,
/// JSON envelope key used in request bodies (e.g., "rule", "vlan", "host")
pub body_key: String,
/// Available CRUD actions for this entity
pub actions: Vec<ActionKind>,
}
/// The kind of CRUD action available on an entity.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum ActionKind {
Search,
Get,
Add,
Set,
Del,
Toggle,
}
#[derive(Debug, thiserror::Error)]
pub enum ControllerParseError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Missing required field: {0}")]
MissingField(String),
}
/// Parse a PHP API controller file and extract its IR.
pub fn parse_controller(path: &Path) -> Result<ControllerIR, ControllerParseError> {
let content = std::fs::read_to_string(path)?;
parse_controller_str(&content)
}
/// Parse a PHP controller that inherits model fields from a parent controller.
/// The caller provides the model_name and model_class from the parent.
pub fn parse_controller_with_defaults(
path: &Path,
default_model_name: &str,
default_model_class: &str,
) -> Result<ControllerIR, ControllerParseError> {
let content = std::fs::read_to_string(path)?;
parse_controller_str_with_defaults(&content, default_model_name, default_model_class)
}
/// Parse with fallback defaults for inherited model fields.
pub fn parse_controller_str_with_defaults(
php: &str,
default_model_name: &str,
default_model_class: &str,
) -> Result<ControllerIR, ControllerParseError> {
let namespace = extract_namespace(php)
.ok_or_else(|| ControllerParseError::MissingField("namespace".into()))?;
let (class_name, parent_class) =
extract_class(php).ok_or_else(|| ControllerParseError::MissingField("class".into()))?;
let model_name =
extract_static_string(php, "internalModelName").unwrap_or(default_model_name.to_string());
let model_class =
extract_static_string(php, "internalModelClass").unwrap_or(default_model_class.to_string());
let module = derive_module(&namespace);
let controller = derive_controller_path(&class_name);
let actions = extract_actions(php);
let body_keys = extract_body_keys(php);
let (entities, standalone_actions) = group_actions(&actions, &body_keys, &model_name);
Ok(ControllerIR {
module,
controller,
model_name,
model_class,
parent_class,
entities,
standalone_actions,
})
}
/// Parse PHP controller source text.
pub fn parse_controller_str(php: &str) -> Result<ControllerIR, ControllerParseError> {
let namespace = extract_namespace(php)
.ok_or_else(|| ControllerParseError::MissingField("namespace".into()))?;
let (class_name, parent_class) =
extract_class(php).ok_or_else(|| ControllerParseError::MissingField("class".into()))?;
let model_name = extract_static_string(php, "internalModelName")
.ok_or_else(|| ControllerParseError::MissingField("internalModelName".into()))?;
let model_class = extract_static_string(php, "internalModelClass")
.ok_or_else(|| ControllerParseError::MissingField("internalModelClass".into()))?;
let module = derive_module(&namespace);
let controller = derive_controller_path(&class_name);
let actions = extract_actions(php);
let body_keys = extract_body_keys(php);
let (entities, standalone_actions) = group_actions(&actions, &body_keys, &model_name);
Ok(ControllerIR {
module,
controller,
model_name,
model_class,
parent_class,
entities,
standalone_actions,
})
}
fn extract_namespace(php: &str) -> Option<String> {
let re = Regex::new(r"namespace\s+([\w\\]+);").unwrap();
re.captures(php).map(|c| c[1].to_string())
}
fn extract_class(php: &str) -> Option<(String, String)> {
let re = Regex::new(r"class\s+(\w+)\s+extends\s+(\w+)").unwrap();
re.captures(php)
.map(|c| (c[1].to_string(), c[2].to_string()))
}
fn extract_static_string(php: &str, var_name: &str) -> Option<String> {
let pattern = format!(
r#"protected\s+static\s+\${}(?:\s*:\s*\??\s*string)?\s*=\s*['"](.*?)['"]"#,
regex::escape(var_name)
);
let re = Regex::new(&pattern).unwrap();
re.captures(php).map(|c| c[1].to_string())
}
fn extract_actions(php: &str) -> Vec<String> {
let re = Regex::new(r"public\s+function\s+(\w+Action)").unwrap();
re.captures_iter(php).map(|c| c[1].to_string()).collect()
}
/// Derive the API module path from the PHP namespace.
/// `OPNsense\Firewall\Api` → `firewall`
/// `OPNsense\Interfaces\Api` → `interfaces`
/// `OPNsense\Dnsmasq\Api` → `dnsmasq`
fn derive_module(namespace: &str) -> String {
let parts: Vec<&str> = namespace.split('\\').collect();
// The module is the part between OPNsense and Api
// e.g., OPNsense\Firewall\Api → Firewall → firewall
if parts.len() >= 3 {
parts[1].to_snake_case()
} else {
namespace.to_snake_case()
}
}
/// Derive the API controller path from the PHP class name.
/// `DNatController` → `d_nat`
/// `VlanSettingsController` → `vlan_settings`
/// `FilterController` → `filter`
fn derive_controller_path(class_name: &str) -> String {
let name = class_name.strip_suffix("Controller").unwrap_or(class_name);
// OPNsense uses a specific conversion: insert underscores before uppercase
// letters that follow lowercase letters, then lowercase everything.
// DNat → d_nat, VlanSettings → vlan_settings
let mut result = String::new();
for (i, ch) in name.chars().enumerate() {
if ch.is_uppercase() && i > 0 {
let prev = name.chars().nth(i - 1).unwrap();
if prev.is_lowercase() || prev.is_ascii_digit() {
result.push('_');
} else if i + 1 < name.len() {
// Handle sequences like "DNat" → "d_nat": insert _ before the
// last uppercase in a consecutive run if followed by lowercase
let next = name.chars().nth(i + 1).unwrap();
if next.is_lowercase() {
result.push('_');
}
}
}
result.push(ch.to_ascii_lowercase());
}
result
}
/// Extract body keys from PHP source by looking at addBase/setBase calls.
/// Returns a map from entity name (PascalCase) to body key string.
fn extract_body_keys(php: &str) -> std::collections::HashMap<String, String> {
let re = Regex::new(r#"(?:addBase|setBase)\s*\(\s*['"]([\w]+)['"]"#).unwrap();
let action_re = Regex::new(
r#"function\s+(add|set)(\w+)Action[^{]*\{[^}]*(?:addBase|setBase)\s*\(\s*['"]([\w]+)['"]"#,
)
.unwrap();
let mut keys = std::collections::HashMap::new();
// Try matching function name + base call together
for caps in action_re.captures_iter(php) {
let entity = caps[2].to_string();
let body_key = caps[3].to_string();
keys.insert(entity, body_key);
}
// Fallback: if no matches, try just the first addBase call
if keys.is_empty()
&& let Some(caps) = re.captures(php)
{
keys.insert("default".to_string(), caps[1].to_string());
}
keys
}
/// Group raw action names into entities and standalone actions.
fn group_actions(
actions: &[String],
body_keys: &std::collections::HashMap<String, String>,
model_name: &str,
) -> (Vec<EntityIR>, Vec<String>) {
let crud_re = Regex::new(r"^(search|get|add|set|del|toggle)(\w+)Action$").unwrap();
let mut entity_map: std::collections::BTreeMap<String, Vec<ActionKind>> =
std::collections::BTreeMap::new();
let mut standalone = Vec::new();
for action in actions {
if let Some(caps) = crud_re.captures(action) {
let verb = &caps[1];
let entity = caps[2].to_string();
let kind = match verb {
"search" => ActionKind::Search,
"get" => ActionKind::Get,
"add" => ActionKind::Add,
"set" => ActionKind::Set,
"del" => ActionKind::Del,
"toggle" => ActionKind::Toggle,
_ => continue,
};
entity_map.entry(entity).or_default().push(kind);
} else {
let name = action.strip_suffix("Action").unwrap_or(action);
standalone.push(name.to_string());
}
}
let entities = entity_map
.into_iter()
.map(|(name, actions)| {
// Determine body key: from PHP extraction, or convention
let body_key = body_keys
.get(&name)
.or_else(|| body_keys.get("default"))
.cloned()
.unwrap_or_else(|| {
if name == "Item" {
model_name.to_lowercase()
} else {
name.to_snake_case()
}
});
EntityIR {
name,
body_key,
actions,
}
})
.collect();
(entities, standalone)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_derive_controller_path() {
assert_eq!(derive_controller_path("DNatController"), "d_nat");
assert_eq!(
derive_controller_path("VlanSettingsController"),
"vlan_settings"
);
assert_eq!(derive_controller_path("FilterController"), "filter");
assert_eq!(
derive_controller_path("LaggSettingsController"),
"lagg_settings"
);
assert_eq!(
derive_controller_path("VipSettingsController"),
"vip_settings"
);
assert_eq!(derive_controller_path("SettingsController"), "settings");
}
#[test]
fn test_derive_module() {
assert_eq!(derive_module("OPNsense\\Firewall\\Api"), "firewall");
assert_eq!(derive_module("OPNsense\\Interfaces\\Api"), "interfaces");
assert_eq!(derive_module("OPNsense\\Dnsmasq\\Api"), "dnsmasq");
}
#[test]
fn test_parse_dnat_controller() {
let php = r#"
namespace OPNsense\Firewall\Api;
class DNatController extends FilterBaseController
{
protected static $internalModelName = 'DNat';
protected static $internalModelClass = 'OPNsense\\Firewall\\DNat';
public function searchRuleAction() {}
public function setRuleAction($uuid) {}
public function addRuleAction() {}
public function getRuleAction($uuid = null) {}
public function delRuleAction($uuid) {}
public function toggleRuleAction($uuid, $disabled = null) {}
public function applyAction($rollback_revision = null) {}
}
"#;
let ir = parse_controller_str(php).unwrap();
assert_eq!(ir.module, "firewall");
assert_eq!(ir.controller, "d_nat");
assert_eq!(ir.model_name, "DNat");
assert_eq!(ir.parent_class, "FilterBaseController");
assert_eq!(ir.entities.len(), 1);
assert_eq!(ir.entities[0].name, "Rule");
assert_eq!(ir.entities[0].actions.len(), 6); // search, get, add, set, del, toggle
assert!(ir.standalone_actions.contains(&"apply".to_string()));
}
#[test]
fn test_parse_vlan_controller() {
let php = r#"
namespace OPNsense\Interfaces\Api;
class VlanSettingsController extends ApiMutableModelControllerBase
{
protected static $internalModelName = 'vlan';
protected static $internalModelClass = 'OPNsense\Interfaces\Vlan';
public function searchItemAction() {}
public function setItemAction($uuid) {}
public function addItemAction() {}
public function getItemAction($uuid = null) {}
public function delItemAction($uuid) {}
public function reconfigureAction() {}
}
"#;
let ir = parse_controller_str(php).unwrap();
assert_eq!(ir.module, "interfaces");
assert_eq!(ir.controller, "vlan_settings");
assert_eq!(ir.model_name, "vlan");
assert_eq!(ir.entities.len(), 1);
assert_eq!(ir.entities[0].name, "Item");
assert!(ir.standalone_actions.contains(&"reconfigure".to_string()));
}
}